新增内容判断引擎文章发布接口

This commit is contained in:
Rei
2026-09-11 16:55:32 +08:00
parent dd34718c8c
commit dd0326e30e
12 changed files with 860 additions and 21 deletions
+189
View File
@@ -0,0 +1,189 @@
import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';
import express from 'express';
import { ApiError, createContentIngestRouter, plainTextToParagraphHtml } from '../src/content-ingest.js';
const TOKEN = 'test-content-ingest-token';
const HASH_V1 = 'a'.repeat(64);
const HASH_V2 = 'b'.repeat(64);
let server;
let baseUrl;
let repository;
class MemoryRepository {
constructor() {
this.articles = new Map();
this.requests = new Map();
}
async upsert(input, publicSiteUrl) {
const request = this.requests.get(input.idempotencyKey);
if (request) {
if (request.contentHash !== input.contentHash) {
throw new ApiError(409, 'CONTENT_CONFLICT', '相同Idempotency-Key对应了不同正文哈希');
}
return { created: false, result: { ...request.result } };
}
const identity = `${input.sourceSystem}:${input.sourceTaskId}:${input.siteProfile}`;
const existing = this.articles.get(identity);
const article = existing || { id: `art-${this.articles.size + 1}`, slug: input.requestedSlug, revision: 0 };
article.revision += 1;
article.title = input.title;
article.bodyPlain = input.bodyPlain;
article.contentHtml = input.contentHtml;
article.status = input.publishMode === 'publish' ? 'published' : 'draft';
article.contentHash = input.contentHash;
article.publishedAt = new Date().toISOString();
this.articles.set(identity, article);
const result = {
publication_id: article.id,
status: article.status,
url: `${publicSiteUrl}/insights/${article.slug}`,
revision: article.revision,
content_hash: article.contentHash,
published_at: article.publishedAt,
};
this.requests.set(input.idempotencyKey, { contentHash: input.contentHash, result });
return { created: !existing, result };
}
}
function payload(overrides = {}) {
return {
request_id: 'request-1',
source_system: 'amaba-content-core',
source_task_id: 'task-1',
source_version_id: 'version-1',
source_version_number: 1,
content_hash: HASH_V1,
site_profile: 'human',
publish_mode: 'publish',
article: {
title: '测试文章',
body_plain: '第一自然段。\n第二自然段。',
summary: '测试摘要',
slug: 'test-article',
primary_keyword: '企业进化',
keywords: ['企业进化'],
content_type: 'industry',
direction: '行业观察',
target_audience: '企业经营者',
author_name: '阿美巴',
cover_image_url: null,
canonical_url: null,
},
quality: { human_confirmed: true, confirmed_at: '2026-09-11T10:00:00+08:00', remaining_issue_levels: ['P3'] },
provenance: { knowledge_refs: [] },
semantic: { entities: [], questions: [], facts: [], suggested_schema_type: 'Article' },
...overrides,
};
}
async function request(path, { token = TOKEN, body, key = 'task-1:version-1:human', method = 'POST' } = {}) {
const headers = {};
if (token !== null) headers.Authorization = `Bearer ${token}`;
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (key !== null) headers['Idempotency-Key'] = key;
return fetch(`${baseUrl}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
}
before(async () => {
repository = new MemoryRepository();
const app = express();
app.use(express.json({ limit: '5mb' }));
app.use('/api/internal/content/v1', createContentIngestRouter({ repository, token: TOKEN, publicSiteUrl: 'https://www.ameiba.cn', rateLimitMax: 100 }));
server = app.listen(0, '127.0.0.1');
await new Promise((resolve) => server.once('listening', resolve));
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
after(() => new Promise((resolve) => server.close(resolve)));
test('健康检查需要正确Token并返回human站点身份', async () => {
const unauthorized = await request('/api/internal/content/v1/health', { method: 'GET', token: null, key: null });
assert.equal(unauthorized.status, 401);
const response = await request('/api/internal/content/v1/health', { method: 'GET', key: null });
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { status: 'ok', service: 'amaba-human-website', site_profile: 'human', api_version: 'v1' });
});
test('未配置发布Token时接口返回503', async () => {
const app = express();
app.use(express.json());
app.use('/api/internal/content/v1', createContentIngestRouter({ repository: new MemoryRepository(), token: '' }));
const localServer = app.listen(0, '127.0.0.1');
await new Promise((resolve) => localServer.once('listening', resolve));
const response = await fetch(`http://127.0.0.1:${localServer.address().port}/api/internal/content/v1/health`);
assert.equal(response.status, 503);
await new Promise((resolve) => localServer.close(resolve));
});
test('错误Token返回401', async () => {
const response = await request('/api/internal/content/v1/articles/upsert', { token: 'wrong', body: payload() });
assert.equal(response.status, 401);
});
test('缺少标题返回400', async () => {
const data = payload();
data.article.title = '';
assert.equal((await request('/api/internal/content/v1/articles/upsert', { body: data })).status, 400);
});
test('缺少正文返回400', async () => {
const data = payload();
data.article.body_plain = '';
assert.equal((await request('/api/internal/content/v1/articles/upsert', { body: data })).status, 400);
});
test('未人工确认时拒绝发布', async () => {
const data = payload();
data.quality.human_confirmed = false;
assert.equal((await request('/api/internal/content/v1/articles/upsert', { body: data })).status, 403);
});
test('错误site_profile被拒绝', async () => {
const data = payload({ site_profile: 'ai' });
assert.equal((await request('/api/internal/content/v1/articles/upsert', { body: data })).status, 403);
});
test('首次发布返回201并生成公开详情URL', async () => {
const response = await request('/api/internal/content/v1/articles/upsert', { body: payload() });
const result = await response.json();
assert.equal(response.status, 201);
assert.equal(result.success, true);
assert.equal(result.revision, 1);
assert.equal(result.url, 'https://www.ameiba.cn/insights/test-article');
assert.equal(repository.articles.size, 1);
});
test('相同幂等键重复请求不创建重复文章', async () => {
const response = await request('/api/internal/content/v1/articles/upsert', { body: payload() });
assert.equal(response.status, 200);
assert.equal((await response.json()).publication_id, 'art-1');
assert.equal(repository.articles.size, 1);
});
test('相同幂等键与不同哈希返回409', async () => {
const response = await request('/api/internal/content/v1/articles/upsert', { body: payload({ content_hash: HASH_V2 }) });
assert.equal(response.status, 409);
});
test('新版本更新同一文章、保留URL并增加revision', async () => {
const data = payload({ source_version_id: 'version-2', source_version_number: 2, content_hash: HASH_V2 });
data.article.slug = 'should-not-replace-original-url';
data.article.body_plain = '新版本正文。';
const response = await request('/api/internal/content/v1/articles/upsert', { body: data, key: 'task-1:version-2:human' });
const result = await response.json();
assert.equal(response.status, 200);
assert.equal(result.publication_id, 'art-1');
assert.equal(result.url, 'https://www.ameiba.cn/insights/test-article');
assert.equal(result.revision, 2);
assert.equal(repository.articles.size, 1);
});
test('纯文本按自然段转义,HTML和脚本不会执行', () => {
assert.equal(
plainTextToParagraphHtml('第一段\n\n<script>alert(1)</script>\n<b>文字</b>'),
'<p>第一段</p>\n<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>\n<p>&lt;b&gt;文字&lt;/b&gt;</p>',
);
});