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

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
+5
View File
@@ -48,10 +48,15 @@ DB_NAME=ameiba_cms
DB_CONNECTION_LIMIT=10 DB_CONNECTION_LIMIT=10
JWT_SECRET=请填写随机且长期不变的密钥 JWT_SECRET=请填写随机且长期不变的密钥
CMS_ADMIN_PASSWORD=首次初始化后台密码 CMS_ADMIN_PASSWORD=首次初始化后台密码
CONTENT_INGEST_TOKEN=请填写内容判断引擎专用随机密钥
CONTENT_INGEST_RATE_LIMIT=30
PUBLIC_SITE_URL=https://www.ameiba.cn
``` ```
生产环境使用 MySQL;`cms.db` 仅用于历史 SQLite 数据迁移,不作为当前生产数据库。不要把真实 `.env`、数据库备份或上传文件提交到 Git。 生产环境使用 MySQL;`cms.db` 仅用于历史 SQLite 数据迁移,不作为当前生产数据库。不要把真实 `.env`、数据库备份或上传文件提交到 Git。
内容判断引擎通过独立的内部发布接口向“认知观察”新增或更新文章,不复用后台 JWT。协议、鉴权、请求示例和幂等规则见 [`docs/内容判断引擎文章发布API.md`](docs/内容判断引擎文章发布API.md)。
## CMS 内容对应关系 ## CMS 内容对应关系
后台地址:`/admin/`。保存后的已发布内容由 `/api/cms/public` 提供,前台刷新后读取最新内容。 后台地址:`/admin/`。保存后的已发布内容由 `/api/cms/public` 提供,前台刷新后读取最新内容。
+15 -15
View File
@@ -1,15 +1,15 @@
NODE_ENV=production NODE_ENV=
CMS_PORT=13001 CMS_PORT=
JWT_SECRET=replace-with-a-long-random-secret DB_HOST=
CMS_ADMIN_PASSWORD=change-on-first-login DB_PORT=
DB_USER=
# MySQL 生产配置 DB_PASSWORD=
DB_HOST=127.0.0.1 DB_NAME=
DB_PORT=33306 DB_CONNECTION_LIMIT=
DB_USER=admin JWT_SECRET=
DB_PASSWORD=replace-with-database-password CMS_ADMIN_PASSWORD=
DB_NAME=ameiba_cms CONTENT_INGEST_TOKEN=
DB_CONNECTION_LIMIT=10 CONTENT_INGEST_RATE_LIMIT=
PUBLIC_SITE_URL=
UPLOAD_DIR=/var/www/ameiba-cn-new/cms/server/uploads CORS_ORIGIN=
CORS_ORIGIN=https://www.ameiba.cn UPLOAD_DIR=
+1
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "node src/index.js", "dev": "node src/index.js",
"start": "node src/index.js", "start": "node src/index.js",
"test": "node --test test/*.test.js",
"seed": "node src/seed.js", "seed": "node src/seed.js",
"passwd": "node src/change-password.js" "passwd": "node src/change-password.js"
}, },
+305
View File
@@ -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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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 };
+74
View File
@@ -60,6 +60,50 @@ export async function initDB() {
const [rows] = await pool.query(`SHOW COLUMNS FROM global_config LIKE ?`, [column]); 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`); 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'); } 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 }; export { UPLOAD_DIR, DB_HOST, DB_PORT, DB_NAME };
+8
View File
@@ -10,6 +10,7 @@ import crypto from 'crypto';
import PDFDocument from 'pdfkit'; import PDFDocument from 'pdfkit';
import { initDB, getDB, UPLOAD_DIR } from './db.js'; import { initDB, getDB, UPLOAD_DIR } from './db.js';
import { signToken, verifyToken, authMiddleware } from './middleware/auth.js'; import { signToken, verifyToken, authMiddleware } from './middleware/auth.js';
import { createContentIngestRouter } from './content-ingest.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express(); const app = express();
@@ -124,6 +125,7 @@ function rateLimit({ windowMs = 60_000, max = 30 } = {}) {
} }
const publicRateLimit = rateLimit({ windowMs: 60_000, max: 60 }); const publicRateLimit = rateLimit({ windowMs: 60_000, max: 60 });
app.use('/api/diagnosis', publicRateLimit); app.use('/api/diagnosis', publicRateLimit);
app.use('/api/internal/content/v1', createContentIngestRouter());
app.post('/api/contact/leads', (req, res) => { app.post('/api/contact/leads', (req, res) => {
const { name, company = '', phone, need = '' } = req.body || {}; 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) => { app.use((err, req, res, next) => {
if (res.headersSent) return next(err); 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') { if (err?.message?.includes('仅支持') || err?.code === 'LIMIT_FILE_SIZE' || err?.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ code: 400, msg: err.message || '上传文件不符合要求' }); return res.status(400).json({ code: 400, msg: err.message || '上传文件不符合要求' });
} }
+2
View File
@@ -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(); if (req.path.startsWith('/admin') || req.path.startsWith('/uploads') || req.path === '/health' || req.path === '/') return next();
// 诊断相关全部公开(问卷、会话、报告、留资、分享、PDF)- 但CMS诊断后台需鉴权 // 诊断相关全部公开(问卷、会话、报告、留资、分享、PDF)- 但CMS诊断后台需鉴权
if (req.path.startsWith('/api/diagnosis/')) return next(); if (req.path.startsWith('/api/diagnosis/')) return next();
// 内容判断引擎使用独立的服务端 Bearer Token,由专用路由自行鉴权。
if (req.path.startsWith('/api/internal/content/v1/')) return next();
// 所有 CMS 管理接口(包括 GET)都必须鉴权;只有下方聚合公开接口例外。 // 所有 CMS 管理接口(包括 GET)都必须鉴权;只有下方聚合公开接口例外。
if (req.path === '/api/auth/login' || req.path === '/api/cms/public' || req.path === '/api/contact/leads') return next(); if (req.path === '/api/auth/login' || req.path === '/api/cms/public' || req.path === '/api/contact/leads') return next();
// 上传也需鉴权 // 上传也需鉴权
+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>',
);
});
+249
View File
@@ -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 转义。
+1 -1
View File
@@ -33,7 +33,7 @@ export default function InsightDetail() {
<h1>{item.title}</h1> <h1>{item.title}</h1>
<p>{item.summary}</p> <p>{item.summary}</p>
<p style={{ fontSize: 12, color: 'rgba(255,255,255,0.6)', marginTop: 8 }}> <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> </p>
</div> </div>
</div> </div>
+1 -1
View File
@@ -28,7 +28,7 @@ export default function Insights() {
<h3 style={{ marginTop: 6 }}>{i.title}</h3> <h3 style={{ marginTop: 6 }}>{i.title}</h3>
<p>{i.summary}</p> <p>{i.summary}</p>
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 8 }}> <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> </div>
</Link> </Link>
))} ))}
+10 -4
View File
@@ -117,21 +117,27 @@ function normalizeCases(raw:any[]): Case[]{
} }
function normalizeInsights(raw:any[]): Insight[]{ function normalizeInsights(raw:any[]): Insight[]{
if(!raw) return FALLBACK_INSIGHTS 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, id: r.id,
slug: r.slug, slug: r.slug,
title: r.title, title: r.title,
summary: r.subtitle || '', summary: r.subtitle || '',
category: (r.category as any)||'other', category: (r.category as any)||'other',
author: r.author_name || '阿美巴',
readingMinutes: Math.max(1, Math.ceil(characterCount / 400)),
status: (r.status as any)||'published', status: (r.status as any)||'published',
sortWeight: r.recommend_weight||0, sortWeight: r.recommend_weight||0,
cmsManaged: true, cmsManaged: true,
cmsId: r.id, 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(), updatedAt: r.updated_at||new Date().toISOString(),
coverImage: r.cover_url, 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[]{ function normalizeMethods(raw:any[]): MethodCapability[]{
if(!raw) return FALLBACK_METHODS if(!raw) return FALLBACK_METHODS