新增内容引擎文章发布接口与详情页

This commit is contained in:
amb
2026-09-11 17:19:09 +08:00
parent ba6aab722e
commit 726485dccb
16 changed files with 958 additions and 102 deletions
+2
View File
@@ -6,3 +6,5 @@ DB_NAME=aiameiba_com_new
DB_USER=admin DB_USER=admin
DB_PASSWORD=填入本地密码 DB_PASSWORD=填入本地密码
ADMIN_INITIAL_PASSWORD=首次部署时设置的管理员初始密码 ADMIN_INITIAL_PASSWORD=首次部署时设置的管理员初始密码
CONTENT_INGEST_TOKEN=
PUBLIC_SITE_URL=https://www.aiameiba.com
+160
View File
@@ -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>
);
}
+27 -90
View File
@@ -1,134 +1,71 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { generateSEO, generateBreadcrumbSchema } from '@/lib/seo'; import { generateSEO, generateBreadcrumbSchema } from '@/lib/seo';
import { getSiteSettings } from '@/lib/settings'; import { getSiteSettings } from '@/lib/settings';
import { articleUrl, getPublishedArticles } from '@/lib/articles';
const API_URL = process.env.API_URL || 'http://localhost:24731'; const categories = ['通用深度', '百科认知', '行业观察', '方法论', '问答', '指南干货'];
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 []; }
}
export const metadata: Metadata = { export const metadata: Metadata = {
...generateSEO({ ...generateSEO({
title: '知识中心', title: '知识中心',
description: '企业进化的知识体系与方法论,包括企业进化、AI时代企业、企业经营等分类', description: '企业进化的知识体系与方法论,包括通用深度、行业观察、方法论和指南干货。',
canonical: '/knowledge', 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() { export default async function KnowledgePage() {
const settings = await getSiteSettings(); const [settings, articles] = await Promise.all([getSiteSettings(), getPublishedArticles()]);
const articles = await getArticles();
const breadcrumbs = [ const breadcrumbs = [
{ name: '首页', url: '/' }, { name: '首页', url: '/' },
{ name: '知识中心', url: '/knowledge' }, { 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 ( 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"> <div className="container">
{/* 面包屑导航 */}
<nav className="breadcrumb" aria-label="面包屑导航"> <nav className="breadcrumb" aria-label="面包屑导航">
<ol> <ol><li><a href="/">首页</a></li><li><span aria-current="page">知识中心</span></li></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>
</nav> </nav>
<h1>{settings['knowledge.title'] || '知识中心'}</h1> <h1>{settings['knowledge.title'] || '知识中心'}</h1>
<section className="content-section"> <section className="content-section">
<h2>{settings['knowledge.systemTitle'] || '知识体系'}</h2> <h2>{settings['knowledge.systemTitle'] || '知识体系'}</h2>
<p> <p>{settings['knowledge.intro'] || '知识中心汇集了企业进化的知识体系与方法论,帮助企业理解和掌握AI时代的经营管理方法。'}</p>
{settings['knowledge.intro'] || '知识中心汇集了企业进化的知识体系与方法论,帮助企业理解和掌握AI时代的经营管理方法。'}
</p>
</section> </section>
<section className="content-section"> <section className="content-section" aria-labelledby="knowledge-categories">
<h2>{settings['knowledge.categoryTitle'] || '知识分类'}</h2> <h2 id="knowledge-categories">{settings['knowledge.categoryTitle'] || '知识分类'}</h2>
<div className="category-grid"> <div className="category-grid">
{categories.map((category) => ( {categories.map((category) => {
<div key={category.id} className="category-card"> const count = articles.filter(article => article.category === category).length;
<h3>{category.name}</h3> return <div key={category} className="category-card"><h3>{category}</h3><p>{count} 篇文章</p></div>;
<p>{category.count} 篇文章</p> })}
</div>
))}
</div> </div>
</section> </section>
<section className="content-section"> <section className="content-section" aria-labelledby="knowledge-articles">
<h2>{settings['knowledge.articleTitle'] || '最新文章'}</h2> <h2 id="knowledge-articles">{settings['knowledge.articleTitle'] || '最新文章'}</h2>
{articles.length > 0 ? ( {articles.length ? (
<div className="articles-list"> <div className="articles-list">
{articles.map((article) => ( {articles.map((article) => (
<div key={article.id} className="article-card"> <article key={article.content_id} className="article-card">
<h3> <p className="article-meta"><span>{article.category}</span><span>{article.author_name}</span><span>{formatDate(article.published_at)}</span></p>
<a href={`/knowledge/${article.slug}`}>{article.title}</a> <h3><a href={articleUrl(article)}>{article.title}</a></h3>
</h3>
<p className="article-meta">
<span>{article.category}</span>
<span>{article.author}</span>
<span>{article.date}</span>
</p>
<p>{article.summary}</p> <p>{article.summary}</p>
</div> <a className="article-read-more" href={articleUrl(article)}>阅读全文</a>
</article>
))} ))}
</div> </div>
) : ( ) : (
<div className="empty-state"> <div className="empty-state"><p>暂无已发布文章</p><p>文章内容正在完善中,请稍后再来查看。</p></div>
<p>暂无文章数据</p>
<p>文章内容正在完善中,请稍后再来查看。</p>
</div>
)} )}
</section> </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> </div>
</> </>
); );
+20
View File
@@ -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' } });
}
+19
View File
@@ -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' } });
}
+10 -6
View File
@@ -1,8 +1,9 @@
import { MetadataRoute } from 'next'; 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 = [ const staticPages = [
{ url: BASE_URL, lastModified: new Date(), changeFrequency: 'weekly' as const, priority: 1.0 }, { 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 }, { url: `${BASE_URL}/contact`, lastModified: new Date(), changeFrequency: 'monthly' as const, priority: 0.6 },
]; ];
// 动态页面(从CMS获取) const articles = await getPublishedArticles();
// 这里需要在实际部署时从API获取数据 const dynamicPages: MetadataRoute.Sitemap = articles.map(article => ({
// 暂时返回静态页面 url: `${BASE_URL}${articleUrl(article)}`,
const dynamicPages: MetadataRoute.Sitemap = []; lastModified: new Date(article.updated_at || article.published_at),
changeFrequency: 'monthly' as const,
priority: 0.7,
}));
return [...staticPages, ...dynamicPages]; return [...staticPages, ...dynamicPages];
} }
+31
View File
@@ -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}`;
+50
View File
@@ -610,6 +610,56 @@ body {
gap: 12px; 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 { .faq-item {
padding: 22px 24px; padding: 22px 24px;
border: 1px solid var(--color-line); border: 1px solid var(--color-line);
+20
View File
@@ -56,6 +56,26 @@ export interface Article extends Content {
author?: string; 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 { export interface Case extends Content {
content_type: 'case'; content_type: 'case';
client_name?: string; client_name?: string;
+35
View File
@@ -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('数据库表创建完成'); console.log('数据库表创建完成');
}; };
+9 -1
View File
@@ -14,13 +14,14 @@ const entityContentJoinRoutes = require('./routes/entityContentJoinRoutes');
const publicApiRoutes = require('./routes/publicApiRoutes'); const publicApiRoutes = require('./routes/publicApiRoutes');
const jsonLdRoutes = require('./routes/jsonLdRoutes'); const jsonLdRoutes = require('./routes/jsonLdRoutes');
const adminRoutes = require('./routes/adminRoutes'); const adminRoutes = require('./routes/adminRoutes');
const internalContentRoutes = require('./routes/internalContentRoutes');
const app = express(); const app = express();
const PORT = process.env.PORT || 24731; const PORT = process.env.PORT || 24731;
// 中间件 // 中间件
app.use(cors({ origin: true, credentials: true })); app.use(cors({ origin: true, credentials: true }));
app.use(express.json()); app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
// 静态文件服务 // 静态文件服务
@@ -34,6 +35,7 @@ app.use('/api/joins', entityContentJoinRoutes);
app.use('/api/public', publicApiRoutes); app.use('/api/public', publicApiRoutes);
app.use('/api/jsonld', jsonLdRoutes); app.use('/api/jsonld', jsonLdRoutes);
app.use('/api/admin', adminRoutes); app.use('/api/admin', adminRoutes);
app.use('/api/internal/content/v1', internalContentRoutes);
// 健康检查 // 健康检查
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
@@ -59,6 +61,12 @@ app.get('/', (req, res) => {
// 错误处理中间件 // 错误处理中间件
app.use((err, req, res, next) => { 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); console.error('服务器错误:', err);
res.status(500).json({ error: '服务器内部错误' }); res.status(500).json({ error: '服务器内部错误' });
}); });
+72
View File
@@ -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;
+28
View File
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const PublicApiService = require('../services/PublicApiService'); const PublicApiService = require('../services/PublicApiService');
const { getDb } = require('../config/database'); const { getDb } = require('../config/database');
const { ContentIngestService } = require('../services/ContentIngestService');
// 获取前台展示配置(只读) // 获取前台展示配置(只读)
router.get('/settings', async (req, res) => { 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) => { router.get('/entities', async (req, res) => {
try { try {
+317
View File
@@ -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 };
+78
View File
@@ -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);
});
});