|
|
|
|
@ -1,7 +1,7 @@
|
|
|
|
|
const urlUtils = require('../../../shared/url-utils');
|
|
|
|
|
const models = require('../../models');
|
|
|
|
|
const getPostServiceInstance = require('../../services/posts/posts-service');
|
|
|
|
|
const allowedIncludes = [
|
|
|
|
|
const allowedIncludes = [ //允许包含的关联数据
|
|
|
|
|
'tags',
|
|
|
|
|
'authors',
|
|
|
|
|
'authors.roles',
|
|
|
|
|
@ -18,12 +18,13 @@ const allowedIncludes = [
|
|
|
|
|
'post_revisions',
|
|
|
|
|
'post_revisions.author'
|
|
|
|
|
];
|
|
|
|
|
const unsafeAttrs = ['status', 'authors', 'visibility'];
|
|
|
|
|
const unsafeAttrs = ['status', 'authors', 'visibility']; //不安全属性列表
|
|
|
|
|
|
|
|
|
|
const postsService = getPostServiceInstance();
|
|
|
|
|
const postsService = getPostServiceInstance(); //文章服务实例
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string} event
|
|
|
|
|
* 根据文章状态变更事件生成缓存失效头信息
|
|
|
|
|
*/
|
|
|
|
|
function getCacheHeaderFromEventString(event, dto) {
|
|
|
|
|
if (event === 'published_updated' || event === 'unpublished') {
|
|
|
|
|
@ -44,76 +45,98 @@ function getCacheHeaderFromEventString(event, dto) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @type {import('@tryghost/api-framework').Controller} */
|
|
|
|
|
/**
|
|
|
|
|
* Ghost CMS 文章 API 控制器
|
|
|
|
|
* 提供对文章的各种操作的api 控制器
|
|
|
|
|
* 这个控制器实现了完整的文章 CRUD(创建、读取、更新、删除)操作,
|
|
|
|
|
* 以及批量操作、数据导出等高级功能。它遵循 Ghost API 框架规范,
|
|
|
|
|
* 每个端点都包含完整的配置:权限控制、参数验证、缓存管理等。
|
|
|
|
|
*
|
|
|
|
|
* @type {import('@tryghost/api-framework').Controller}
|
|
|
|
|
*/
|
|
|
|
|
const controller = {
|
|
|
|
|
// 控制器文档名称,用于 API 文档生成
|
|
|
|
|
docName: 'posts',
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 获取文章列表端点
|
|
|
|
|
* 支持分页、过滤、排序、字段选择等高级查询功能
|
|
|
|
|
*/
|
|
|
|
|
browse: {
|
|
|
|
|
headers: {
|
|
|
|
|
cacheInvalidate: false
|
|
|
|
|
cacheInvalidate: false // 列表查询不缓存失效
|
|
|
|
|
},
|
|
|
|
|
options: [
|
|
|
|
|
'include',
|
|
|
|
|
'filter',
|
|
|
|
|
'fields',
|
|
|
|
|
'collection',
|
|
|
|
|
'formats',
|
|
|
|
|
'limit',
|
|
|
|
|
'order',
|
|
|
|
|
'page',
|
|
|
|
|
'debug',
|
|
|
|
|
'absolute_urls'
|
|
|
|
|
'include', // 包含关联数据(标签、作者等)
|
|
|
|
|
'filter', // 过滤条件
|
|
|
|
|
'fields', // 选择返回字段
|
|
|
|
|
'collection', // 集合过滤
|
|
|
|
|
'formats', // 内容格式
|
|
|
|
|
'limit', // 分页大小
|
|
|
|
|
'order', // 排序方式
|
|
|
|
|
'page', // 页码
|
|
|
|
|
'debug', // 调试模式
|
|
|
|
|
'absolute_urls' // 绝对URL
|
|
|
|
|
],
|
|
|
|
|
validation: {
|
|
|
|
|
options: {
|
|
|
|
|
include: {
|
|
|
|
|
values: allowedIncludes
|
|
|
|
|
values: allowedIncludes // 只允许预定义的关联数据
|
|
|
|
|
},
|
|
|
|
|
formats: {
|
|
|
|
|
values: models.Post.allowedFormats
|
|
|
|
|
values: models.Post.allowedFormats // 只允许支持的格式
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
permissions: {
|
|
|
|
|
unsafeAttrs: unsafeAttrs
|
|
|
|
|
unsafeAttrs: unsafeAttrs // 限制不安全属性修改
|
|
|
|
|
},
|
|
|
|
|
query(frame) {
|
|
|
|
|
return postsService.browsePosts(frame.options);
|
|
|
|
|
return postsService.browsePosts(frame.options); // 调用服务层
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 导出文章分析数据为 CSV 格式
|
|
|
|
|
* 用于数据分析和报表生成
|
|
|
|
|
*/
|
|
|
|
|
exportCSV: {
|
|
|
|
|
options: [
|
|
|
|
|
'limit',
|
|
|
|
|
'filter',
|
|
|
|
|
'order'
|
|
|
|
|
'limit', // 导出数量限制
|
|
|
|
|
'filter', // 过滤条件
|
|
|
|
|
'order' // 排序方式
|
|
|
|
|
],
|
|
|
|
|
headers: {
|
|
|
|
|
disposition: {
|
|
|
|
|
type: 'csv',
|
|
|
|
|
type: 'csv', // 文件类型
|
|
|
|
|
value() {
|
|
|
|
|
const datetime = (new Date()).toJSON().substring(0, 10);
|
|
|
|
|
return `post-analytics.${datetime}.csv`;
|
|
|
|
|
return `post-analytics.${datetime}.csv`; // 带时间戳的文件名
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
cacheInvalidate: false
|
|
|
|
|
cacheInvalidate: false // 导出操作不缓存失效
|
|
|
|
|
},
|
|
|
|
|
response: {
|
|
|
|
|
format: 'plain'
|
|
|
|
|
format: 'plain' // 纯文本响应格式
|
|
|
|
|
},
|
|
|
|
|
permissions: {
|
|
|
|
|
method: 'browse'
|
|
|
|
|
method: 'browse' // 复用浏览权限
|
|
|
|
|
},
|
|
|
|
|
validation: {},
|
|
|
|
|
async query(frame) {
|
|
|
|
|
return {
|
|
|
|
|
data: await postsService.export(frame)
|
|
|
|
|
data: await postsService.export(frame) // 调用导出服务
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 获取单篇文章详情
|
|
|
|
|
* 支持通过 ID、slug 或 UUID 查询
|
|
|
|
|
*/
|
|
|
|
|
read: {
|
|
|
|
|
headers: {
|
|
|
|
|
cacheInvalidate: false
|
|
|
|
|
cacheInvalidate: false // 单篇文章查询不缓存失效
|
|
|
|
|
},
|
|
|
|
|
options: [
|
|
|
|
|
'include',
|
|
|
|
|
@ -121,14 +144,14 @@ const controller = {
|
|
|
|
|
'formats',
|
|
|
|
|
'debug',
|
|
|
|
|
'absolute_urls',
|
|
|
|
|
// NOTE: only for internal context
|
|
|
|
|
'forUpdate',
|
|
|
|
|
'transacting'
|
|
|
|
|
// 内部上下文专用选项
|
|
|
|
|
'forUpdate', // 用于更新操作
|
|
|
|
|
'transacting' // 事务处理
|
|
|
|
|
],
|
|
|
|
|
data: [
|
|
|
|
|
'id',
|
|
|
|
|
'slug',
|
|
|
|
|
'uuid'
|
|
|
|
|
'id', // 文章ID
|
|
|
|
|
'slug', // 文章别名
|
|
|
|
|
'uuid' // 全局唯一标识
|
|
|
|
|
],
|
|
|
|
|
validation: {
|
|
|
|
|
options: {
|
|
|
|
|
@ -144,19 +167,23 @@ const controller = {
|
|
|
|
|
unsafeAttrs: unsafeAttrs
|
|
|
|
|
},
|
|
|
|
|
query(frame) {
|
|
|
|
|
return postsService.readPost(frame);
|
|
|
|
|
return postsService.readPost(frame); // 调用读取服务
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 创建新文章
|
|
|
|
|
* 状态码 201 表示创建成功
|
|
|
|
|
*/
|
|
|
|
|
add: {
|
|
|
|
|
statusCode: 201,
|
|
|
|
|
statusCode: 201, // 创建成功状态码
|
|
|
|
|
headers: {
|
|
|
|
|
cacheInvalidate: false
|
|
|
|
|
cacheInvalidate: false // 默认不缓存失效
|
|
|
|
|
},
|
|
|
|
|
options: [
|
|
|
|
|
'include',
|
|
|
|
|
'formats',
|
|
|
|
|
'source'
|
|
|
|
|
'source' // 内容来源(HTML)
|
|
|
|
|
],
|
|
|
|
|
validation: {
|
|
|
|
|
options: {
|
|
|
|
|
@ -164,7 +191,7 @@ const controller = {
|
|
|
|
|
values: allowedIncludes
|
|
|
|
|
},
|
|
|
|
|
source: {
|
|
|
|
|
values: ['html']
|
|
|
|
|
values: ['html'] // 只支持HTML源
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
@ -173,6 +200,7 @@ const controller = {
|
|
|
|
|
},
|
|
|
|
|
async query(frame) {
|
|
|
|
|
const model = await models.Post.add(frame.data.posts[0], frame.options);
|
|
|
|
|
// 如果文章状态为已发布,则失效所有缓存
|
|
|
|
|
if (model.get('status') === 'published') {
|
|
|
|
|
frame.setHeader('X-Cache-Invalidate', '/*');
|
|
|
|
|
}
|
|
|
|
|
@ -181,22 +209,26 @@ const controller = {
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 编辑文章
|
|
|
|
|
* 支持智能缓存失效和事件处理
|
|
|
|
|
*/
|
|
|
|
|
edit: {
|
|
|
|
|
headers: {
|
|
|
|
|
/** @type {boolean | {value: string}} */
|
|
|
|
|
cacheInvalidate: false
|
|
|
|
|
cacheInvalidate: false // 初始不缓存失效
|
|
|
|
|
},
|
|
|
|
|
options: [
|
|
|
|
|
'include',
|
|
|
|
|
'id',
|
|
|
|
|
'id', // 必须提供文章ID
|
|
|
|
|
'formats',
|
|
|
|
|
'source',
|
|
|
|
|
'email_segment',
|
|
|
|
|
'newsletter',
|
|
|
|
|
'force_rerender',
|
|
|
|
|
'save_revision',
|
|
|
|
|
'convert_to_lexical',
|
|
|
|
|
// NOTE: only for internal context
|
|
|
|
|
'email_segment', // 邮件分段
|
|
|
|
|
'newsletter', // 新闻稿设置
|
|
|
|
|
'force_rerender', // 强制重新渲染
|
|
|
|
|
'save_revision', // 保存修订版本
|
|
|
|
|
'convert_to_lexical', // 转换为Lexical格式
|
|
|
|
|
// 内部上下文专用选项
|
|
|
|
|
'forUpdate',
|
|
|
|
|
'transacting'
|
|
|
|
|
],
|
|
|
|
|
@ -206,7 +238,7 @@ const controller = {
|
|
|
|
|
values: allowedIncludes
|
|
|
|
|
},
|
|
|
|
|
id: {
|
|
|
|
|
required: true
|
|
|
|
|
required: true // ID为必填项
|
|
|
|
|
},
|
|
|
|
|
source: {
|
|
|
|
|
values: ['html']
|
|
|
|
|
@ -218,12 +250,13 @@ const controller = {
|
|
|
|
|
},
|
|
|
|
|
async query(frame) {
|
|
|
|
|
let model = await postsService.editPost(frame, {
|
|
|
|
|
// 事件处理器,根据文章状态变更智能处理缓存
|
|
|
|
|
eventHandler: (event, dto) => {
|
|
|
|
|
const cacheInvalidate = getCacheHeaderFromEventString(event, dto);
|
|
|
|
|
if (cacheInvalidate === true) {
|
|
|
|
|
frame.setHeader('X-Cache-Invalidate', '/*');
|
|
|
|
|
frame.setHeader('X-Cache-Invalidate', '/*'); // 失效所有缓存
|
|
|
|
|
} else if (cacheInvalidate?.value) {
|
|
|
|
|
frame.setHeader('X-Cache-Invalidate', cacheInvalidate.value);
|
|
|
|
|
frame.setHeader('X-Cache-Invalidate', cacheInvalidate.value); // 失效特定URL缓存
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
@ -232,62 +265,74 @@ const controller = {
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 批量编辑文章
|
|
|
|
|
* 基于过滤条件对多篇文章执行相同操作
|
|
|
|
|
*/
|
|
|
|
|
bulkEdit: {
|
|
|
|
|
statusCode: 200,
|
|
|
|
|
statusCode: 200, // 操作成功状态码
|
|
|
|
|
headers: {
|
|
|
|
|
cacheInvalidate: true
|
|
|
|
|
cacheInvalidate: true // 批量操作需要缓存失效
|
|
|
|
|
},
|
|
|
|
|
options: [
|
|
|
|
|
'filter'
|
|
|
|
|
'filter' // 必须提供过滤条件
|
|
|
|
|
],
|
|
|
|
|
data: [
|
|
|
|
|
'action',
|
|
|
|
|
'meta'
|
|
|
|
|
'action', // 操作类型(必填)
|
|
|
|
|
'meta' // 操作元数据
|
|
|
|
|
],
|
|
|
|
|
validation: {
|
|
|
|
|
data: {
|
|
|
|
|
action: {
|
|
|
|
|
required: true
|
|
|
|
|
required: true // 操作类型为必填项
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
options: {
|
|
|
|
|
filter: {
|
|
|
|
|
required: true
|
|
|
|
|
required: true // 过滤条件为必填项
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
permissions: {
|
|
|
|
|
method: 'edit'
|
|
|
|
|
method: 'edit' // 复用编辑权限
|
|
|
|
|
},
|
|
|
|
|
async query(frame) {
|
|
|
|
|
return await postsService.bulkEdit(frame.data.bulk, frame.options);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 批量删除文章
|
|
|
|
|
* 基于过滤条件删除多篇文章
|
|
|
|
|
*/
|
|
|
|
|
bulkDestroy: {
|
|
|
|
|
statusCode: 200,
|
|
|
|
|
headers: {
|
|
|
|
|
cacheInvalidate: true
|
|
|
|
|
cacheInvalidate: true // 删除操作需要缓存失效
|
|
|
|
|
},
|
|
|
|
|
options: [
|
|
|
|
|
'filter'
|
|
|
|
|
'filter' // 必须提供过滤条件
|
|
|
|
|
],
|
|
|
|
|
permissions: {
|
|
|
|
|
method: 'destroy'
|
|
|
|
|
method: 'destroy' // 复用删除权限
|
|
|
|
|
},
|
|
|
|
|
async query(frame) {
|
|
|
|
|
return await postsService.bulkDestroy(frame.options);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 删除单篇文章
|
|
|
|
|
* 状态码 204 表示无内容返回(删除成功)
|
|
|
|
|
*/
|
|
|
|
|
destroy: {
|
|
|
|
|
statusCode: 204,
|
|
|
|
|
statusCode: 204, // 删除成功状态码(无内容)
|
|
|
|
|
headers: {
|
|
|
|
|
cacheInvalidate: true
|
|
|
|
|
cacheInvalidate: true // 删除操作需要缓存失效
|
|
|
|
|
},
|
|
|
|
|
options: [
|
|
|
|
|
'include',
|
|
|
|
|
'id'
|
|
|
|
|
'id' // 必须提供文章ID
|
|
|
|
|
],
|
|
|
|
|
validation: {
|
|
|
|
|
options: {
|
|
|
|
|
@ -295,7 +340,7 @@ const controller = {
|
|
|
|
|
values: allowedIncludes
|
|
|
|
|
},
|
|
|
|
|
id: {
|
|
|
|
|
required: true
|
|
|
|
|
required: true // ID为必填项
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
@ -303,34 +348,39 @@ const controller = {
|
|
|
|
|
unsafeAttrs: unsafeAttrs
|
|
|
|
|
},
|
|
|
|
|
query(frame) {
|
|
|
|
|
return models.Post.destroy({...frame.options, require: true});
|
|
|
|
|
return models.Post.destroy({...frame.options, require: true}); // 直接调用模型层
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 复制文章
|
|
|
|
|
* 创建文章的副本,保留原文章内容但生成新的标识
|
|
|
|
|
*/
|
|
|
|
|
copy: {
|
|
|
|
|
statusCode: 201,
|
|
|
|
|
statusCode: 201, // 创建成功状态码
|
|
|
|
|
headers: {
|
|
|
|
|
location: {
|
|
|
|
|
// 生成复制后文章的位置URL
|
|
|
|
|
resolve: postsService.generateCopiedPostLocationFromUrl
|
|
|
|
|
},
|
|
|
|
|
cacheInvalidate: false
|
|
|
|
|
cacheInvalidate: false // 复制操作不缓存失效
|
|
|
|
|
},
|
|
|
|
|
options: [
|
|
|
|
|
'id',
|
|
|
|
|
'formats'
|
|
|
|
|
'id', // 必须提供原文章ID
|
|
|
|
|
'formats' // 内容格式
|
|
|
|
|
],
|
|
|
|
|
validation: {
|
|
|
|
|
id: {
|
|
|
|
|
required: true
|
|
|
|
|
required: true // ID为必填项
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
permissions: {
|
|
|
|
|
method: 'add'
|
|
|
|
|
method: 'add' // 复用添加权限
|
|
|
|
|
},
|
|
|
|
|
async query(frame) {
|
|
|
|
|
return postsService.copyPost(frame);
|
|
|
|
|
return postsService.copyPost(frame); // 调用复制服务
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
module.exports = controller;
|
|
|
|
|
module.exports = controller;
|