新增内容判断引擎文章发布接口
This commit is contained in:
@@ -48,10 +48,15 @@ DB_NAME=ameiba_cms
|
||||
DB_CONNECTION_LIMIT=10
|
||||
JWT_SECRET=请填写随机且长期不变的密钥
|
||||
CMS_ADMIN_PASSWORD=首次初始化后台密码
|
||||
CONTENT_INGEST_TOKEN=请填写内容判断引擎专用随机密钥
|
||||
CONTENT_INGEST_RATE_LIMIT=30
|
||||
PUBLIC_SITE_URL=https://www.ameiba.cn
|
||||
```
|
||||
|
||||
生产环境使用 MySQL;`cms.db` 仅用于历史 SQLite 数据迁移,不作为当前生产数据库。不要把真实 `.env`、数据库备份或上传文件提交到 Git。
|
||||
|
||||
内容判断引擎通过独立的内部发布接口向“认知观察”新增或更新文章,不复用后台 JWT。协议、鉴权、请求示例和幂等规则见 [`docs/内容判断引擎文章发布API.md`](docs/内容判断引擎文章发布API.md)。
|
||||
|
||||
## CMS 内容对应关系
|
||||
|
||||
后台地址:`/admin/`。保存后的已发布内容由 `/api/cms/public` 提供,前台刷新后读取最新内容。
|
||||
|
||||
+15
-15
@@ -1,15 +1,15 @@
|
||||
NODE_ENV=production
|
||||
CMS_PORT=13001
|
||||
JWT_SECRET=replace-with-a-long-random-secret
|
||||
CMS_ADMIN_PASSWORD=change-on-first-login
|
||||
|
||||
# MySQL 生产配置
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=33306
|
||||
DB_USER=admin
|
||||
DB_PASSWORD=replace-with-database-password
|
||||
DB_NAME=ameiba_cms
|
||||
DB_CONNECTION_LIMIT=10
|
||||
|
||||
UPLOAD_DIR=/var/www/ameiba-cn-new/cms/server/uploads
|
||||
CORS_ORIGIN=https://www.ameiba.cn
|
||||
NODE_ENV=
|
||||
CMS_PORT=
|
||||
DB_HOST=
|
||||
DB_PORT=
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_NAME=
|
||||
DB_CONNECTION_LIMIT=
|
||||
JWT_SECRET=
|
||||
CMS_ADMIN_PASSWORD=
|
||||
CONTENT_INGEST_TOKEN=
|
||||
CONTENT_INGEST_RATE_LIMIT=
|
||||
PUBLIC_SITE_URL=
|
||||
CORS_ORIGIN=
|
||||
UPLOAD_DIR=
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "node src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"test": "node --test test/*.test.js",
|
||||
"seed": "node src/seed.js",
|
||||
"passwd": "node src/change-password.js"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import crypto from 'crypto';
|
||||
import express from 'express';
|
||||
import { withTransaction } from './db.js';
|
||||
|
||||
const SOURCE_SYSTEM = 'amaba-content-core';
|
||||
const SITE_PROFILE = 'human';
|
||||
const MAX_TITLE_LENGTH = 200;
|
||||
const MAX_BODY_LENGTH = 200_000;
|
||||
const MAX_SUMMARY_LENGTH = 1_000;
|
||||
const MAX_ARRAY_ITEMS = 30;
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(status, code, message, retryable = false) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.retryable = retryable;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(res, error) {
|
||||
const status = error instanceof ApiError ? error.status : 500;
|
||||
const code = error instanceof ApiError ? error.code : 'INTERNAL_ERROR';
|
||||
const message = error instanceof ApiError ? error.message : '网站内部错误';
|
||||
const retryable = error instanceof ApiError ? error.retryable : true;
|
||||
return res.status(status).json({ success: false, error: { code, message, retryable } });
|
||||
}
|
||||
|
||||
function text(value, max, field, required = false) {
|
||||
const result = typeof value === 'string' ? value.trim() : '';
|
||||
if (required && !result) throw new ApiError(400, 'VALIDATION_ERROR', `${field}不能为空`);
|
||||
if (result.length > max) throw new ApiError(413, 'CONTENT_TOO_LARGE', `${field}超过长度限制`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function stringArray(value, field) {
|
||||
if (value == null) return [];
|
||||
if (!Array.isArray(value)) throw new ApiError(400, 'VALIDATION_ERROR', `${field}必须是数组`);
|
||||
if (value.length > MAX_ARRAY_ITEMS) throw new ApiError(400, 'VALIDATION_ERROR', `${field}最多${MAX_ARRAY_ITEMS}项`);
|
||||
return value.map((item) => text(item, 100, `${field}元素`, true));
|
||||
}
|
||||
|
||||
function jsonObject(value, field) {
|
||||
if (value == null) return {};
|
||||
if (typeof value !== 'object' || Array.isArray(value)) throw new ApiError(400, 'VALIDATION_ERROR', `${field}必须是对象`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function limitedJson(value, field, maxLength = 50_000) {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized.length > maxLength) throw new ApiError(413, 'CONTENT_TOO_LARGE', `${field}超过长度限制`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalHttpUrl(value, field) {
|
||||
const result = text(value, 2_000, field);
|
||||
if (!result) return null;
|
||||
try {
|
||||
const parsed = new URL(result);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('protocol');
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
throw new ApiError(400, 'VALIDATION_ERROR', `${field}必须是有效的HTTP或HTTPS地址`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeSlug(value, sourceTaskId) {
|
||||
const supplied = text(value, 160, 'article.slug');
|
||||
if (supplied) {
|
||||
const normalized = supplied.toLowerCase();
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(normalized)) {
|
||||
throw new ApiError(400, 'VALIDATION_ERROR', 'article.slug只能包含小写字母、数字和连字符');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
const taskPart = sourceTaskId.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 120);
|
||||
return `article-${taskPart || crypto.createHash('sha256').update(sourceTaskId).digest('hex').slice(0, 24)}`;
|
||||
}
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
export function plainTextToParagraphHtml(value) {
|
||||
return String(value)
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split('\n')
|
||||
.map((paragraph) => paragraph.trim())
|
||||
.filter(Boolean)
|
||||
.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function validateIngestRequest(body, idempotencyKey) {
|
||||
const payload = jsonObject(body, '请求体');
|
||||
const article = jsonObject(payload.article, 'article');
|
||||
const quality = jsonObject(payload.quality, 'quality');
|
||||
const provenance = jsonObject(payload.provenance, 'provenance');
|
||||
const semantic = jsonObject(payload.semantic, 'semantic');
|
||||
const sourceSystem = text(payload.source_system, 100, 'source_system', true);
|
||||
const sourceTaskId = text(payload.source_task_id, 191, 'source_task_id', true);
|
||||
const sourceVersionId = text(payload.source_version_id, 191, 'source_version_id', true);
|
||||
const profile = text(payload.site_profile, 32, 'site_profile', true);
|
||||
const key = text(idempotencyKey, 191, 'Idempotency-Key', true);
|
||||
const expectedKey = `${sourceTaskId}:${sourceVersionId}:${SITE_PROFILE}`;
|
||||
|
||||
if (sourceSystem !== SOURCE_SYSTEM) throw new ApiError(403, 'SOURCE_NOT_ALLOWED', `只允许source_system=${SOURCE_SYSTEM}`);
|
||||
if (profile !== SITE_PROFILE) throw new ApiError(403, 'SITE_PROFILE_MISMATCH', `site_profile必须为${SITE_PROFILE}`);
|
||||
if (quality.human_confirmed !== true) throw new ApiError(403, 'HUMAN_CONFIRMATION_REQUIRED', '仅允许发布人工确认的终稿');
|
||||
if (key !== expectedKey) throw new ApiError(400, 'VALIDATION_ERROR', `Idempotency-Key必须为${expectedKey}`);
|
||||
|
||||
const sourceVersionNumber = Number(payload.source_version_number);
|
||||
if (!Number.isInteger(sourceVersionNumber) || sourceVersionNumber < 1) {
|
||||
throw new ApiError(400, 'VALIDATION_ERROR', 'source_version_number必须是正整数');
|
||||
}
|
||||
const contentHash = text(payload.content_hash, 64, 'content_hash', true).toLowerCase();
|
||||
if (!/^[a-f0-9]{64}$/.test(contentHash)) throw new ApiError(400, 'VALIDATION_ERROR', 'content_hash必须是64位SHA256十六进制值');
|
||||
const publishMode = text(payload.publish_mode || 'publish', 16, 'publish_mode');
|
||||
if (!['publish', 'draft'].includes(publishMode)) throw new ApiError(400, 'VALIDATION_ERROR', 'publish_mode仅支持publish或draft');
|
||||
|
||||
const bodyPlain = text(article.body_plain, MAX_BODY_LENGTH, 'article.body_plain', true).replace(/\r\n?/g, '\n');
|
||||
const keywords = stringArray(article.keywords, 'article.keywords');
|
||||
const remainingIssueLevels = stringArray(quality.remaining_issue_levels, 'quality.remaining_issue_levels');
|
||||
const knowledgeRefs = Array.isArray(provenance.knowledge_refs) ? provenance.knowledge_refs : [];
|
||||
if (knowledgeRefs.length > MAX_ARRAY_ITEMS) throw new ApiError(400, 'VALIDATION_ERROR', `provenance.knowledge_refs最多${MAX_ARRAY_ITEMS}项`);
|
||||
const entities = stringArray(semantic.entities, 'semantic.entities');
|
||||
const questions = stringArray(semantic.questions, 'semantic.questions');
|
||||
if (semantic.facts != null && !Array.isArray(semantic.facts)) throw new ApiError(400, 'VALIDATION_ERROR', 'semantic.facts必须是数组');
|
||||
if ((semantic.facts || []).length > MAX_ARRAY_ITEMS) throw new ApiError(400, 'VALIDATION_ERROR', `semantic.facts最多${MAX_ARRAY_ITEMS}项`);
|
||||
|
||||
return {
|
||||
requestId: text(payload.request_id, 191, 'request_id', true),
|
||||
sourceSystem,
|
||||
sourceTaskId,
|
||||
sourceVersionId,
|
||||
sourceVersionNumber,
|
||||
siteProfile: profile,
|
||||
idempotencyKey: key,
|
||||
contentHash,
|
||||
publishMode,
|
||||
title: text(article.title, MAX_TITLE_LENGTH, 'article.title', true),
|
||||
bodyPlain,
|
||||
contentHtml: plainTextToParagraphHtml(bodyPlain),
|
||||
summary: text(article.summary, MAX_SUMMARY_LENGTH, 'article.summary'),
|
||||
requestedSlug: safeSlug(article.slug, sourceTaskId),
|
||||
primaryKeyword: text(article.primary_keyword, 255, 'article.primary_keyword'),
|
||||
keywords,
|
||||
contentType: text(article.content_type, 64, 'article.content_type'),
|
||||
direction: text(article.direction, 191, 'article.direction'),
|
||||
targetAudience: text(article.target_audience, 500, 'article.target_audience'),
|
||||
authorName: text(article.author_name || '阿美巴', 191, 'article.author_name'),
|
||||
coverImageUrl: optionalHttpUrl(article.cover_image_url, 'article.cover_image_url'),
|
||||
canonicalUrl: optionalHttpUrl(article.canonical_url, 'article.canonical_url'),
|
||||
humanConfirmed: true,
|
||||
confirmedAt: text(quality.confirmed_at, 40, 'quality.confirmed_at', true),
|
||||
quality: limitedJson({ ...quality, remaining_issue_levels: remainingIssueLevels }, 'quality'),
|
||||
provenance: limitedJson({ ...provenance, knowledge_refs: knowledgeRefs }, 'provenance'),
|
||||
semantic: limitedJson({ ...semantic, entities, questions, facts: semantic.facts || [] }, 'semantic'),
|
||||
};
|
||||
}
|
||||
|
||||
function publicationResult(row, publicSiteUrl, contentHash = row.content_hash, revision = row.revision) {
|
||||
return {
|
||||
publication_id: row.id,
|
||||
status: row.status,
|
||||
url: `${publicSiteUrl}/insights/${encodeURIComponent(row.slug)}`,
|
||||
revision: Number(revision),
|
||||
content_hash: contentHash,
|
||||
published_at: row.publish_time || row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMysqlArticleRepository() {
|
||||
return {
|
||||
async upsert(input, publicSiteUrl) {
|
||||
return withTransaction(async (tx) => {
|
||||
const priorRequest = await tx.one(`SELECT * FROM article_ingest_requests WHERE idempotency_key=? FOR UPDATE`, [input.idempotencyKey]);
|
||||
if (priorRequest) {
|
||||
if (priorRequest.content_hash !== input.contentHash) {
|
||||
throw new ApiError(409, 'CONTENT_CONFLICT', '相同Idempotency-Key对应了不同正文哈希');
|
||||
}
|
||||
const article = await tx.one(`SELECT * FROM articles WHERE id=?`, [priorRequest.article_id]);
|
||||
if (!article) throw new ApiError(500, 'INTERNAL_ERROR', '幂等记录对应的文章不存在', true);
|
||||
return { created: false, replayed: true, result: publicationResult(article, publicSiteUrl, priorRequest.content_hash, priorRequest.revision) };
|
||||
}
|
||||
|
||||
const existing = await tx.one(
|
||||
`SELECT * FROM articles WHERE source_system=? AND source_task_id=? AND site_profile=? FOR UPDATE`,
|
||||
[input.sourceSystem, input.sourceTaskId, input.siteProfile],
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const status = input.publishMode === 'publish' ? 'published' : 'draft';
|
||||
let articleId;
|
||||
let slug;
|
||||
let revision;
|
||||
|
||||
if (existing) {
|
||||
if (input.sourceVersionNumber <= Number(existing.source_version_number || 0)) {
|
||||
throw new ApiError(409, 'CONTENT_CONFLICT', '新发布版本号必须高于网站当前版本');
|
||||
}
|
||||
articleId = existing.id;
|
||||
slug = existing.slug;
|
||||
revision = Number(existing.revision || 1) + 1;
|
||||
await tx.execute(`UPDATE articles SET
|
||||
title=?, subtitle=?, category=?, cover_url=?, content_html=?, body_plain=?, publish_time=?, status=?,
|
||||
source_version_id=?, source_version_number=?, idempotency_key=?, content_hash=?, revision=?, primary_keyword=?,
|
||||
keywords_json=?, content_type=?, direction=?, target_audience=?, author_name=?, human_confirmed=?, confirmed_at=?,
|
||||
request_id=?, canonical_url=?, quality_json=?, provenance_json=?, semantic_json=?, updated_at=? WHERE id=?`, [
|
||||
input.title, input.summary, input.direction || input.contentType || '通用深度', input.coverImageUrl,
|
||||
input.contentHtml, input.bodyPlain, status === 'published' ? now : existing.publish_time, status,
|
||||
input.sourceVersionId, input.sourceVersionNumber, input.idempotencyKey, input.contentHash, revision,
|
||||
input.primaryKeyword, JSON.stringify(input.keywords), input.contentType, input.direction, input.targetAudience,
|
||||
input.authorName, 1, input.confirmedAt, input.requestId, input.canonicalUrl, JSON.stringify(input.quality),
|
||||
JSON.stringify(input.provenance), JSON.stringify(input.semantic), now, articleId,
|
||||
]);
|
||||
} else {
|
||||
const slugOwner = await tx.one(`SELECT id FROM articles WHERE slug=?`, [input.requestedSlug]);
|
||||
if (slugOwner) throw new ApiError(409, 'CONTENT_CONFLICT', '请求的slug已被其他文章使用');
|
||||
articleId = `art-${crypto.randomUUID()}`;
|
||||
slug = input.requestedSlug;
|
||||
revision = 1;
|
||||
await tx.execute(`INSERT INTO articles (
|
||||
id, slug, title, subtitle, category, cover_url, content_html, body_plain, publish_time, recommend_weight,
|
||||
status, source_system, source_task_id, source_version_id, source_version_number, site_profile,
|
||||
idempotency_key, content_hash, revision, primary_keyword, keywords_json, content_type, direction,
|
||||
target_audience, author_name, human_confirmed, confirmed_at, request_id, canonical_url,
|
||||
quality_json, provenance_json, semantic_json, created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, [
|
||||
articleId, slug, input.title, input.summary, input.direction || input.contentType || '通用深度',
|
||||
input.coverImageUrl, input.contentHtml, input.bodyPlain, status === 'published' ? now : null, 0,
|
||||
status, input.sourceSystem, input.sourceTaskId, input.sourceVersionId, input.sourceVersionNumber,
|
||||
input.siteProfile, input.idempotencyKey, input.contentHash, revision, input.primaryKeyword,
|
||||
JSON.stringify(input.keywords), input.contentType, input.direction, input.targetAudience,
|
||||
input.authorName, 1, input.confirmedAt, input.requestId, input.canonicalUrl,
|
||||
JSON.stringify(input.quality), JSON.stringify(input.provenance), JSON.stringify(input.semantic), now, now,
|
||||
]);
|
||||
}
|
||||
|
||||
await tx.execute(`INSERT INTO article_ingest_requests (idempotency_key, article_id, source_version_id, content_hash, revision, created_at) VALUES (?,?,?,?,?,?)`,
|
||||
[input.idempotencyKey, articleId, input.sourceVersionId, input.contentHash, revision, now]);
|
||||
const row = await tx.one(`SELECT * FROM articles WHERE id=?`, [articleId]);
|
||||
return { created: !existing, replayed: false, result: publicationResult(row, publicSiteUrl) };
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function secureTokenEquals(actual, expected) {
|
||||
if (!actual || !expected) return false;
|
||||
const actualDigest = crypto.createHash('sha256').update(actual).digest();
|
||||
const expectedDigest = crypto.createHash('sha256').update(expected).digest();
|
||||
return crypto.timingSafeEqual(actualDigest, expectedDigest);
|
||||
}
|
||||
|
||||
export function createContentIngestRouter({
|
||||
repository = createMysqlArticleRepository(),
|
||||
token = process.env.CONTENT_INGEST_TOKEN || '',
|
||||
publicSiteUrl = process.env.PUBLIC_SITE_URL || 'https://www.ameiba.cn',
|
||||
rateLimitMax = Number(process.env.CONTENT_INGEST_RATE_LIMIT || 30),
|
||||
} = {}) {
|
||||
const router = express.Router();
|
||||
const buckets = new Map();
|
||||
const baseUrl = publicSiteUrl.replace(/\/+$/, '');
|
||||
|
||||
router.use((req, res, next) => {
|
||||
if (!token) return fail(res, new ApiError(503, 'SERVICE_UNAVAILABLE', '内容发布接口未配置', true));
|
||||
const now = Date.now();
|
||||
const key = String(req.headers['x-forwarded-for'] || req.ip || req.socket.remoteAddress || 'unknown').split(',')[0].trim();
|
||||
const bucket = buckets.get(key);
|
||||
if (!bucket || now - bucket.startedAt >= 60_000) buckets.set(key, { startedAt: now, count: 1 });
|
||||
else if (++bucket.count > rateLimitMax) return fail(res, new ApiError(429, 'RATE_LIMITED', '请求过于频繁,请稍后再试', true));
|
||||
|
||||
const authorization = String(req.headers.authorization || '');
|
||||
const supplied = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : '';
|
||||
if (!secureTokenEquals(supplied, token)) return fail(res, new ApiError(401, 'UNAUTHORIZED', '发布凭证无效'));
|
||||
next();
|
||||
});
|
||||
|
||||
router.get('/health', (req, res) => res.json({
|
||||
status: 'ok',
|
||||
service: 'amaba-human-website',
|
||||
site_profile: SITE_PROFILE,
|
||||
api_version: 'v1',
|
||||
}));
|
||||
|
||||
router.post('/articles/upsert', async (req, res) => {
|
||||
try {
|
||||
const input = validateIngestRequest(req.body, req.headers['idempotency-key']);
|
||||
const outcome = await repository.upsert(input, baseUrl);
|
||||
res.status(outcome.created ? 201 : 200).json({ success: true, ...outcome.result });
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError)) console.error('[ContentIngest]', error?.code || error?.message || 'unknown error');
|
||||
fail(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export { ApiError, MAX_BODY_LENGTH, MAX_TITLE_LENGTH, SITE_PROFILE, SOURCE_SYSTEM };
|
||||
@@ -60,6 +60,50 @@ export async function initDB() {
|
||||
const [rows] = await pool.query(`SHOW COLUMNS FROM global_config LIKE ?`, [column]);
|
||||
if (!rows.length) await pool.query(`ALTER TABLE global_config ADD COLUMN \`${column}\` LONGTEXT`);
|
||||
}
|
||||
|
||||
const articleColumns = {
|
||||
source_system: 'VARCHAR(100) NULL',
|
||||
source_task_id: 'VARCHAR(191) NULL',
|
||||
source_version_id: 'VARCHAR(191) NULL',
|
||||
source_version_number: 'INT NULL',
|
||||
site_profile: 'VARCHAR(32) NULL',
|
||||
idempotency_key: 'VARCHAR(191) NULL',
|
||||
content_hash: 'VARCHAR(64) NULL',
|
||||
body_plain: 'LONGTEXT NULL',
|
||||
revision: 'INT NOT NULL DEFAULT 1',
|
||||
primary_keyword: 'VARCHAR(255) NULL',
|
||||
keywords_json: 'LONGTEXT NULL',
|
||||
content_type: 'VARCHAR(64) NULL',
|
||||
direction: 'VARCHAR(191) NULL',
|
||||
target_audience: 'VARCHAR(500) NULL',
|
||||
author_name: 'VARCHAR(191) NULL',
|
||||
human_confirmed: 'TINYINT(1) NOT NULL DEFAULT 0',
|
||||
confirmed_at: 'VARCHAR(40) NULL',
|
||||
request_id: 'VARCHAR(191) NULL',
|
||||
canonical_url: 'LONGTEXT NULL',
|
||||
quality_json: 'LONGTEXT NULL',
|
||||
provenance_json: 'LONGTEXT NULL',
|
||||
semantic_json: 'LONGTEXT NULL',
|
||||
};
|
||||
for (const [column, definition] of Object.entries(articleColumns)) {
|
||||
const [rows] = await pool.query(`SHOW COLUMNS FROM articles LIKE ?`, [column]);
|
||||
if (!rows.length) await pool.query(`ALTER TABLE articles ADD COLUMN \`${column}\` ${definition}`);
|
||||
}
|
||||
|
||||
const [idempotencyIndexes] = await pool.query(`SHOW INDEX FROM articles WHERE Key_name='uq_articles_idempotency_key'`);
|
||||
if (!idempotencyIndexes.length) await pool.query(`ALTER TABLE articles ADD UNIQUE INDEX uq_articles_idempotency_key (idempotency_key)`);
|
||||
const [sourceIndexes] = await pool.query(`SHOW INDEX FROM articles WHERE Key_name='uq_articles_source_task_site'`);
|
||||
if (!sourceIndexes.length) await pool.query(`ALTER TABLE articles ADD UNIQUE INDEX uq_articles_source_task_site (source_system, source_task_id, site_profile)`);
|
||||
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS article_ingest_requests (
|
||||
idempotency_key VARCHAR(191) PRIMARY KEY,
|
||||
article_id VARCHAR(191) NOT NULL,
|
||||
source_version_id VARCHAR(191) NOT NULL,
|
||||
content_hash VARCHAR(64) NOT NULL,
|
||||
revision INT NOT NULL,
|
||||
created_at VARCHAR(40) NOT NULL,
|
||||
INDEX idx_article_ingest_article_id (article_id)
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||
}
|
||||
|
||||
function normalizeSql(sql) { return sql.replace(/INSERT OR IGNORE/gi, 'INSERT IGNORE'); }
|
||||
@@ -75,4 +119,34 @@ export function getDB() {
|
||||
};
|
||||
}
|
||||
|
||||
export async function withTransaction(work) {
|
||||
if (!pool) throw new Error('Database is not initialized');
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const tx = {
|
||||
async one(sql, params = []) {
|
||||
const [rows] = await connection.query(normalizeSql(sql), params);
|
||||
return rows[0] || null;
|
||||
},
|
||||
async all(sql, params = []) {
|
||||
const [rows] = await connection.query(normalizeSql(sql), params);
|
||||
return rows;
|
||||
},
|
||||
async execute(sql, params = []) {
|
||||
const [result] = await connection.query(normalizeSql(sql), params);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
const result = await work(tx);
|
||||
await connection.commit();
|
||||
return result;
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
export { UPLOAD_DIR, DB_HOST, DB_PORT, DB_NAME };
|
||||
|
||||
@@ -10,6 +10,7 @@ import crypto from 'crypto';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { initDB, getDB, UPLOAD_DIR } from './db.js';
|
||||
import { signToken, verifyToken, authMiddleware } from './middleware/auth.js';
|
||||
import { createContentIngestRouter } from './content-ingest.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
@@ -124,6 +125,7 @@ function rateLimit({ windowMs = 60_000, max = 30 } = {}) {
|
||||
}
|
||||
const publicRateLimit = rateLimit({ windowMs: 60_000, max: 60 });
|
||||
app.use('/api/diagnosis', publicRateLimit);
|
||||
app.use('/api/internal/content/v1', createContentIngestRouter());
|
||||
|
||||
app.post('/api/contact/leads', (req, res) => {
|
||||
const { name, company = '', phone, need = '' } = req.body || {};
|
||||
@@ -556,6 +558,12 @@ app.get('/api/cms/contact-leads', (req,res)=>{
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
if (res.headersSent) return next(err);
|
||||
if (err?.type === 'entity.too.large') {
|
||||
if (req.path.startsWith('/api/internal/content/v1/')) {
|
||||
return res.status(413).json({ success: false, error: { code: 'CONTENT_TOO_LARGE', message: '请求体超过长度限制', retryable: false } });
|
||||
}
|
||||
return res.status(413).json({ code: 413, msg: '请求体超过长度限制' });
|
||||
}
|
||||
if (err?.message?.includes('仅支持') || err?.code === 'LIMIT_FILE_SIZE' || err?.code === 'LIMIT_FILE_COUNT') {
|
||||
return res.status(400).json({ code: 400, msg: err.message || '上传文件不符合要求' });
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export function authMiddleware(req, res, next) {
|
||||
if (req.path.startsWith('/admin') || req.path.startsWith('/uploads') || req.path === '/health' || req.path === '/') return next();
|
||||
// 诊断相关全部公开(问卷、会话、报告、留资、分享、PDF)- 但CMS诊断后台需鉴权
|
||||
if (req.path.startsWith('/api/diagnosis/')) return next();
|
||||
// 内容判断引擎使用独立的服务端 Bearer Token,由专用路由自行鉴权。
|
||||
if (req.path.startsWith('/api/internal/content/v1/')) return next();
|
||||
// 所有 CMS 管理接口(包括 GET)都必须鉴权;只有下方聚合公开接口例外。
|
||||
if (req.path === '/api/auth/login' || req.path === '/api/cms/public' || req.path === '/api/contact/leads') return next();
|
||||
// 上传也需鉴权
|
||||
|
||||
@@ -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><script>alert(1)</script></p>\n<p><b>文字</b></p>',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
# 阿美巴内容判断引擎文章发布 API
|
||||
|
||||
版本:V1
|
||||
站点身份:`human`
|
||||
公开栏目:认知观察
|
||||
生产站点:`https://www.ameiba.cn`
|
||||
|
||||
本文档供“阿美巴内容判断引擎”调用传统官网发布接口。网站只接收人工确认终稿、保存并安全排版,不调用 AI 二次修改正文。
|
||||
|
||||
## 接口配置
|
||||
|
||||
```text
|
||||
base_url=https://www.ameiba.cn
|
||||
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=human
|
||||
public_url_rule=https://www.ameiba.cn/insights/{slug}
|
||||
```
|
||||
|
||||
所有请求必须使用 HTTPS,并在请求头携带:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <CONTENT_INGEST_TOKEN>
|
||||
```
|
||||
|
||||
发布密钥只保存在双方服务端环境变量中,不得写入前端、Git、请求日志或普通业务日志。
|
||||
|
||||
## 1. 健康检查
|
||||
|
||||
```http
|
||||
GET /api/internal/content/v1/health
|
||||
Authorization: Bearer <CONTENT_INGEST_TOKEN>
|
||||
```
|
||||
|
||||
成功响应,HTTP 200:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "amaba-human-website",
|
||||
"site_profile": "human",
|
||||
"api_version": "v1"
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 新增或更新文章
|
||||
|
||||
```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>:human
|
||||
```
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "01JEXAMPLE",
|
||||
"source_system": "amaba-content-core",
|
||||
"source_task_id": "69900421-8edd-4fb3-9bc6-f656348a416c",
|
||||
"source_version_id": "af6a2665-f8f3-4f16-b99e-14372a311bf8",
|
||||
"source_version_number": 2,
|
||||
"content_hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"site_profile": "human",
|
||||
"publish_mode": "publish",
|
||||
"article": {
|
||||
"title": "文章标题",
|
||||
"body_plain": "第一自然段。\n第二自然段。",
|
||||
"summary": "列表页摘要",
|
||||
"slug": "example-slug",
|
||||
"primary_keyword": "企业AI进化",
|
||||
"keywords": ["企业AI", "组织进化"],
|
||||
"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",
|
||||
"prompt_version": "AmAba-Audit-V1.2",
|
||||
"judgment_version": "AmAba-Judgment-V1.1",
|
||||
"model": "deepseek",
|
||||
"remaining_issue_levels": ["P3"]
|
||||
},
|
||||
"provenance": {
|
||||
"knowledge_refs": [
|
||||
{"id": "knowledge-id", "name": "知识名称", "version": "1.0"}
|
||||
]
|
||||
},
|
||||
"semantic": {
|
||||
"entities": ["阿美巴", "企业AI进化"],
|
||||
"questions": [],
|
||||
"facts": [],
|
||||
"suggested_schema_type": "Article"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
首次创建成功返回 HTTP 201:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"publication_id": "art-网站文章ID",
|
||||
"status": "published",
|
||||
"url": "https://www.ameiba.cn/insights/example-slug",
|
||||
"revision": 1,
|
||||
"content_hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"published_at": "2026-09-11T10:01:20.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
同一来源任务的新确认版本会更新原文章,保留原 `publication_id` 和原公开 URL,`revision` 加 1,返回 HTTP 200。相同幂等键的安全重试也返回 HTTP 200。
|
||||
|
||||
## 字段和校验规则
|
||||
|
||||
- `source_system` 固定为 `amaba-content-core`。
|
||||
- `site_profile` 固定为 `human`。
|
||||
- `quality.human_confirmed` 必须严格为 `true`。
|
||||
- `source_version_number` 必须是正整数;更新时必须高于网站当前版本,防止旧版本倒序覆盖。
|
||||
- `content_hash` 必须为 64 位 SHA-256 十六进制字符串。
|
||||
- `publish_mode` 支持 `publish` 和 `draft`。
|
||||
- `article.title` 最长 200 字符。
|
||||
- `article.body_plain` 最长 200,000 字符。
|
||||
- `article.summary` 最长 1,000 字符。
|
||||
- 关键词、问题、实体、事实、知识引用等数组最多 30 项。
|
||||
- `slug` 可省略;提供时仅允许小写字母、数字和连字符。
|
||||
- `cover_image_url` 和 `canonical_url` 仅接受 HTTP/HTTPS URL。
|
||||
- 接口默认按来源 IP 每分钟限制 30 次,可用 `CONTENT_INGEST_RATE_LIMIT` 调整。
|
||||
|
||||
## 正文安全处理
|
||||
|
||||
`body_plain` 被视为不可信纯文本。服务端会:
|
||||
|
||||
1. 统一换行符。
|
||||
2. 按单换行拆分自然段并忽略空行。
|
||||
3. 转义 `& < > " '` 等 HTML 特殊字符。
|
||||
4. 每个自然段生成独立 `<p>` 存入 `content_html`。
|
||||
5. 同时原样保存规范化后的 `body_plain`。
|
||||
|
||||
前端使用 React 文本节点按自然段渲染,不使用 `dangerouslySetInnerHTML`,因此正文中的 HTML 或 JavaScript 不会执行。
|
||||
|
||||
## 幂等和文章唯一性
|
||||
|
||||
标准幂等键:
|
||||
|
||||
```text
|
||||
<source_task_id>:<source_version_id>:human
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `UNIQUE(idempotency_key)` 防止同一版本重复发布。
|
||||
- `UNIQUE(source_system, source_task_id, site_profile)` 保证同一内容任务始终对应同一网站文章和公开 URL。
|
||||
- 每个发布过的幂等键同时写入 `article_ingest_requests`,因此旧版本请求在新版本发布后重试仍可正确识别。
|
||||
- 相同幂等键和相同哈希:返回原 `publication_id` 与 URL,不写入重复文章。
|
||||
- 相同幂等键但哈希不同:返回 HTTP 409,不静默覆盖。
|
||||
- 新 `source_version_id` 和新幂等键:更新原文章,保留 slug,`revision + 1`。
|
||||
|
||||
## 数据库增量迁移
|
||||
|
||||
现有 MySQL `articles` 表增量增加:
|
||||
|
||||
```text
|
||||
source_system, source_task_id, source_version_id, source_version_number,
|
||||
site_profile, idempotency_key, content_hash, body_plain, revision,
|
||||
primary_keyword, keywords_json, content_type, direction, target_audience,
|
||||
author_name, human_confirmed, confirmed_at, request_id, canonical_url,
|
||||
quality_json, provenance_json, semantic_json
|
||||
```
|
||||
|
||||
新增关联表 `article_ingest_requests` 保存历史幂等键、文章 ID、版本 ID、哈希和当次 revision。迁移位于 `cms/server/src/db.js` 的现有启动迁移流程中,CMS 正常启动时通过 `SHOW COLUMNS`、`ALTER TABLE ADD COLUMN` 和 `CREATE TABLE IF NOT EXISTS` 执行;不会删除、清空或重建现有文章表。
|
||||
|
||||
## 错误响应
|
||||
|
||||
统一格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "article.title不能为空",
|
||||
"retryable": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| HTTP | code | 是否建议自动重试 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 400 | `VALIDATION_ERROR` | 否 | 请求字段或幂等键格式错误 |
|
||||
| 401 | `UNAUTHORIZED` | 否 | 缺少或错误 Token |
|
||||
| 403 | `HUMAN_CONFIRMATION_REQUIRED` | 否 | 不是人工确认终稿 |
|
||||
| 403 | `SOURCE_NOT_ALLOWED` / `SITE_PROFILE_MISMATCH` | 否 | 来源系统或站点身份错误 |
|
||||
| 409 | `CONTENT_CONFLICT` | 否,需人工检查 | 幂等哈希、slug 或版本冲突 |
|
||||
| 413 | `CONTENT_TOO_LARGE` | 否 | 正文或元数据超过限制 |
|
||||
| 429 | `RATE_LIMITED` | 是 | 请求频率超过限制 |
|
||||
| 500 | `INTERNAL_ERROR` | 是 | 网站内部错误 |
|
||||
| 503 | `SERVICE_UNAVAILABLE` | 是 | 生产环境未配置发布密钥或服务暂不可用 |
|
||||
|
||||
## curl 示例
|
||||
|
||||
健康检查:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $CONTENT_INGEST_TOKEN" \
|
||||
https://www.ameiba.cn/api/internal/content/v1/health
|
||||
```
|
||||
|
||||
发布文章:
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer $CONTENT_INGEST_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Idempotency-Key: 69900421-8edd-4fb3-9bc6-f656348a416c:af6a2665-f8f3-4f16-b99e-14372a311bf8:human" \
|
||||
--data @article-publish.json \
|
||||
https://www.ameiba.cn/api/internal/content/v1/articles/upsert
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
```dotenv
|
||||
CONTENT_INGEST_TOKEN=双方服务端共享的高强度随机密钥
|
||||
CONTENT_INGEST_RATE_LIMIT=30
|
||||
PUBLIC_SITE_URL=https://www.ameiba.cn
|
||||
```
|
||||
|
||||
仓库中的 `cms/server/.env.example` 只提供空值名称。真实值写入服务器的 `/opt/cms/server/.env`,不得提交 Git。
|
||||
|
||||
## 本地验证
|
||||
|
||||
```bash
|
||||
cd cms/server
|
||||
npm install
|
||||
npm test
|
||||
|
||||
cd ../..
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
当前验收结果:内部发布接口自动化测试 12 项通过;前端 TypeScript 与 Vite 生产构建通过。自动化测试覆盖 Token、健康检查、字段校验、人工确认、站点身份、首次创建、幂等重试、哈希冲突、同 URL 更新、revision 增长、自然段转换与 XSS 转义。
|
||||
@@ -33,7 +33,7 @@ export default function InsightDetail() {
|
||||
<h1>{item.title}</h1>
|
||||
<p>{item.summary}</p>
|
||||
<p style={{ fontSize: 12, color: 'rgba(255,255,255,0.6)', marginTop: 8 }}>
|
||||
{item.readingMinutes} 分钟 · {item.author} · {item.category}
|
||||
{item.category} · {new Date(item.createdAt).toLocaleDateString('zh-CN')} · {item.author} · {item.readingMinutes} 分钟阅读
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function Insights() {
|
||||
<h3 style={{ marginTop: 6 }}>{i.title}</h3>
|
||||
<p>{i.summary}</p>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 8 }}>
|
||||
{i.readingMinutes} 分钟 · {i.author} · 权重 {i.sortWeight}
|
||||
{i.category} · {new Date(i.createdAt).toLocaleDateString('zh-CN')} · {i.author}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
+10
-4
@@ -117,21 +117,27 @@ function normalizeCases(raw:any[]): Case[]{
|
||||
}
|
||||
function normalizeInsights(raw:any[]): Insight[]{
|
||||
if(!raw) return FALLBACK_INSIGHTS
|
||||
return raw.map(r=>({
|
||||
return raw.map(r=>{
|
||||
const body = String(r.body_plain || r.content_html || '')
|
||||
const paragraphs = body.replace(/\r\n?/g, '\n').split('\n').map((value:string)=>value.trim()).filter(Boolean)
|
||||
const characterCount = body.replace(/\s/g, '').length
|
||||
return ({
|
||||
id: r.id,
|
||||
slug: r.slug,
|
||||
title: r.title,
|
||||
summary: r.subtitle || '',
|
||||
category: (r.category as any)||'other',
|
||||
author: r.author_name || '阿美巴',
|
||||
readingMinutes: Math.max(1, Math.ceil(characterCount / 400)),
|
||||
status: (r.status as any)||'published',
|
||||
sortWeight: r.recommend_weight||0,
|
||||
cmsManaged: true,
|
||||
cmsId: r.id,
|
||||
createdAt: r.created_at||new Date().toISOString(),
|
||||
createdAt: r.publish_time||r.created_at||new Date().toISOString(),
|
||||
updatedAt: r.updated_at||new Date().toISOString(),
|
||||
coverImage: r.cover_url,
|
||||
contentBlocks: r.content_html ? [{id:'b1', type:'paragraph' as const, text: r.content_html}] : [],
|
||||
}))
|
||||
contentBlocks: paragraphs.map((paragraph:string,index:number)=>({id:`p${index+1}`, type:'paragraph' as const, text:paragraph})),
|
||||
})})
|
||||
}
|
||||
function normalizeMethods(raw:any[]): MethodCapability[]{
|
||||
if(!raw) return FALLBACK_METHODS
|
||||
|
||||
Reference in New Issue
Block a user