diff --git a/README.md b/README.md index 7534509..5e353e7 100644 --- a/README.md +++ b/README.md @@ -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` 提供,前台刷新后读取最新内容。 diff --git a/cms/server/.env.example b/cms/server/.env.example index ad47d71..e743339 100644 --- a/cms/server/.env.example +++ b/cms/server/.env.example @@ -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= diff --git a/cms/server/package.json b/cms/server/package.json index a8acde7..23315fd 100644 --- a/cms/server/package.json +++ b/cms/server/package.json @@ -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" }, diff --git a/cms/server/src/content-ingest.js b/cms/server/src/content-ingest.js new file mode 100644 index 0000000..941156e --- /dev/null +++ b/cms/server/src/content-ingest.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) => `

${escapeHtml(paragraph)}

`) + .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 }; diff --git a/cms/server/src/db.js b/cms/server/src/db.js index efb50f7..9b26e75 100644 --- a/cms/server/src/db.js +++ b/cms/server/src/db.js @@ -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 }; diff --git a/cms/server/src/index.js b/cms/server/src/index.js index 95b4fb3..ffafe0f 100644 --- a/cms/server/src/index.js +++ b/cms/server/src/index.js @@ -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 || '上传文件不符合要求' }); } diff --git a/cms/server/src/middleware/auth.js b/cms/server/src/middleware/auth.js index ef09741..98a3156 100644 --- a/cms/server/src/middleware/auth.js +++ b/cms/server/src/middleware/auth.js @@ -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(); // 上传也需鉴权 diff --git a/cms/server/test/content-ingest.test.js b/cms/server/test/content-ingest.test.js new file mode 100644 index 0000000..f1ee990 --- /dev/null +++ b/cms/server/test/content-ingest.test.js @@ -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\n文字'), + '

第一段

\n

<script>alert(1)</script>

\n

<b>文字</b>

', + ); +}); diff --git a/docs/内容判断引擎文章发布API.md b/docs/内容判断引擎文章发布API.md new file mode 100644 index 0000000..efba9d3 --- /dev/null +++ b/docs/内容判断引擎文章发布API.md @@ -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 +``` + +发布密钥只保存在双方服务端环境变量中,不得写入前端、Git、请求日志或普通业务日志。 + +## 1. 健康检查 + +```http +GET /api/internal/content/v1/health +Authorization: Bearer +``` + +成功响应,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-Type: application/json +Idempotency-Key: ::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. 每个自然段生成独立 `

` 存入 `content_html`。 +5. 同时原样保存规范化后的 `body_plain`。 + +前端使用 React 文本节点按自然段渲染,不使用 `dangerouslySetInnerHTML`,因此正文中的 HTML 或 JavaScript 不会执行。 + +## 幂等和文章唯一性 + +标准幂等键: + +```text +::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 转义。 diff --git a/src/pages/Insights/Detail.tsx b/src/pages/Insights/Detail.tsx index f18d518..fb6d067 100644 --- a/src/pages/Insights/Detail.tsx +++ b/src/pages/Insights/Detail.tsx @@ -33,7 +33,7 @@ export default function InsightDetail() {

{item.title}

{item.summary}

- {item.readingMinutes} 分钟 · {item.author} · {item.category} + {item.category} · {new Date(item.createdAt).toLocaleDateString('zh-CN')} · {item.author} · {item.readingMinutes} 分钟阅读

diff --git a/src/pages/Insights/index.tsx b/src/pages/Insights/index.tsx index 3fd9189..344fda3 100644 --- a/src/pages/Insights/index.tsx +++ b/src/pages/Insights/index.tsx @@ -28,7 +28,7 @@ export default function Insights() {

{i.title}

{i.summary}

- {i.readingMinutes} 分钟 · {i.author} · 权重 {i.sortWeight} + {i.category} · {new Date(i.createdAt).toLocaleDateString('zh-CN')} · {i.author}
))} diff --git a/src/services/cms.ts b/src/services/cms.ts index b9588bf..4ead26b 100644 --- a/src/services/cms.ts +++ b/src/services/cms.ts @@ -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