ZYY 6 months ago
commit eec5bd999a

@ -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) // 调用导出服务
};
}
},
/**
* 获取单篇文章详情
* 支持通过 IDslug 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;

@ -5,14 +5,14 @@ const metrics = require('@tryghost/metrics');
const sentry = require('../../../shared/sentry');
const states = {
const states = { //定义数据库状态
READY: 0,
NEEDS_INITIALISATION: 1,
NEEDS_MIGRATION: 2,
ERROR: 3
};
const printState = ({state}) => {
const printState = ({state}) => { //打印当前数据库状态
if (state === states.READY) {
logging.info('Database is in a ready state.');
}
@ -37,12 +37,12 @@ class DatabaseStateManager {
});
}
async getState() {
async getState() { //获得当前数据库状态
let state = states.READY;
try {
await this.knexMigrator.isDatabaseOK();
return state;
} catch (error) {
} catch (error) { //对错误状态进行处理
// CASE: database has not yet been initialized
if (error.code === 'DB_NOT_INITIALISED') {
state = states.NEEDS_INITIALISATION;
@ -71,12 +71,12 @@ class DatabaseStateManager {
});
}
sentry.captureException(errorToThrow);
sentry.captureException(errorToThrow);//记录错误信息
throw errorToThrow;
}
}
async makeReady() {
async makeReady() { //将数据库状态设置为READY
try {
let state = await this.getState();
@ -111,7 +111,7 @@ class DatabaseStateManager {
state = await this.getState();
printState({state});
} catch (error) {
} catch (error) { //对错误状态进行处理
let errorToThrow = error;
if (!errors.utils.isGhostError(error)) {
errorToThrow = new errors.InternalServerError({

@ -12,6 +12,7 @@ const exporter = require('../exporter');
* @param {object} exportResult
* @param {string} exportResult.filename
* @param {object} exportResult.data
* 文件写入功能
*/
const writeExportFile = async (exportResult) => {
const filename = path.resolve(urlUtils.urlJoin(config.get('paths').contentPath, 'data', exportResult.filename));
@ -21,7 +22,8 @@ const writeExportFile = async (exportResult) => {
};
/**
* @param {string} filename
* @param {string} fileName
* 文件读取功能
*/
const readBackup = async (filename) => {
const parsedFileName = path.parse(filename);
@ -43,10 +45,11 @@ const readBackup = async (filename) => {
*
* @param {Object} options
* @returns {Promise<String> | null}
* 数据库备份功能
*/
const backup = async function backup(options = {}) {
// do not create backup if disabled in config (this is intended for large customers who will OOM node)
if (config.get('disableJSBackups')) {
if (config.get('disableJSBackups')) { //检查是否禁用了备份功能
logging.info('Database backup is disabled in Ghost config');
return null;
}

@ -8,17 +8,17 @@ const config = require('../../../shared/config');
const errors = require('@tryghost/errors');
/** @type {knex.Knex} */
let knexInstance;
let knexInstance; //执行的时候才会被赋予一个Knex实例
// @TODO:
// - if you require this file before config file was loaded,
// - then this file is cached and you have no chance to connect to the db anymore
// - bring dynamic into this file (db.connect())
function configure(dbConfig) {
const client = dbConfig.client;
const client = dbConfig.client; //获取但概念的客户端
if (client === 'sqlite3') {
// Backwards compatibility with old knex behaviour
if (client === 'sqlite3') { //向后兼容性如果使用的是sqlite3客户端
// Backwards compatibility with old knex behaviour
dbConfig.useNullAsDefault = Object.prototype.hasOwnProperty.call(dbConfig, 'useNullAsDefault') ? dbConfig.useNullAsDefault : true;
// Enables foreign key checks and delete on cascade
@ -38,6 +38,7 @@ function configure(dbConfig) {
// In the default SQLite test config we set the path to /tmp/ghost-test.db,
// but this won't work on Windows, so we need to replace the /tmp bit with
// the Windows temp folder
// 在windows系统下的兼容性处理
const filename = dbConfig.connection.filename;
if (process.platform === 'win32' && _.isString(filename) && filename.match(/^\/tmp/)) {
dbConfig.connection.filename = filename.replace(/^\/tmp/, os.tmpdir());
@ -47,18 +48,19 @@ function configure(dbConfig) {
if (client === 'mysql2') {
dbConfig.connection.timezone = 'Z';
dbConfig.connection.charset = 'utf8mb4';
dbConfig.connection.decimalNumbers = true;
dbConfig.connection.charset = 'utf8mb4'; //编码方式的设置
dbConfig.connection.decimalNumbers = true; //是否将MySQL的DECIMAL类型转换为JavaScript的Number类型
if (process.env.REQUIRE_INFILE_STREAM) {
if (process.env.NODE_ENV === 'development' || process.env.ALLOW_INFILE_STREAM) {
if (process.env.REQUIRE_INFILE_STREAM) { //是否要求启用infile流
if (process.env.NODE_ENV === 'development' || process.env.ALLOW_INFILE_STREAM) { //如果是在开发环境下或者允许启用infile流
dbConfig.connection.infileStreamFactory = path => fs.createReadStream(path);
} else {
} else {//如果不是在开发环境下并且不允许启用infile流
throw new errors.InternalServerError({message: 'MySQL infile streaming is required to run the current process, but is not allowed. Run the script in development mode or set ALLOW_INFILE_STREAM=1.'});
}
}
}
//如果前两个if都没成功的话就会返回原始的dbConfig对象
//返回数据库配置对象
return dbConfig;
}

@ -1,32 +1,70 @@
/**
* 数据库导出文件名生成模块
*
* 负责为 Ghost 数据库备份文件生成智能安全的文件名
* 文件名格式{站点标题}.ghost.{时间戳}.json
*
* @module exporter/export-filename
*/
const _ = require('lodash');
const logging = require('@tryghost/logging');
const errors = require('@tryghost/errors');
const security = require('@tryghost/security');
const models = require('../../models');
/**
* 数据库模型查询选项配置
* 使用内部上下文权限访问设置数据
*/
const modelOptions = {context: {internal: true}};
/**
* 生成数据库导出文件的文件名
*
* 文件名生成规则
* 1. 如果提供了自定义文件名直接使用
* 2. 否则生成格式{站点标题}.ghost.{-----}.json
* 3. 包含安全过滤和错误处理机制
*
* @param {Object} [options] - 配置选项
* @param {string} [options.filename] - 自定义文件名不含后缀
* @param {Object} [options.transacting] - 事务对象
* @returns {Promise<string>} 生成的完整文件名包含 .json 后缀
*
*/
const exportFileName = async function exportFileName(options) {
// 生成当前时间戳,格式:年-月-日-时-分-秒
const datetime = require('moment')().format('YYYY-MM-DD-HH-mm-ss');
let title = '';
let title = ''; // 站点标题部分,默认为空
// 确保 options 参数不为空
options = options || {};
// custom filename
if (options.filename) {
if (options.filename) { //对文件名进行处理
return options.filename + '.json';
}
try {
const settingsTitle = await models.Settings.findOne({key: 'title'}, _.merge({}, modelOptions, _.pick(options, 'transacting')));
/**
* 从数据库查询站点标题设置
* 使用内部权限上下文支持事务传递
*/
const settingsTitle = await models.Settings.findOne(
{key: 'title'},
_.merge({}, modelOptions, _.pick(options, 'transacting'))
);
if (settingsTitle) {
title = security.string.safe(settingsTitle.get('value')) + '.';
// 如果成功获取到站点标题,进行安全过滤处理
if (settingsTitle) {
title = security.string.safe(settingsTitle.get('value')) + '.'; //对站点标题进行安全性过滤,移除一些可能会出问题的字符
}
return title + 'ghost.' + datetime + '.json';
} catch (err) {
logging.error(new errors.InternalServerError({err: err}));
return title + 'ghost.' + datetime + '.json'; //返回完整的文件名格式
} catch (err) {
logging.error(new errors.InternalServerError({err: err})); //错误处理机制,记录错误日志
// 错误情况下返回默认文件名ghost.{时间戳}.json
return 'ghost.' + datetime + '.json';
}
};

@ -9,13 +9,13 @@ const {sequence} = require('@tryghost/promise');
const messages = {
errorExportingData: 'Error exporting data'
};
//负责将数据库内容到处为可移植的json格式
const {
TABLES_ALLOWLIST,
SETTING_KEYS_BLOCKLIST
} = require('./table-lists');
const exportTable = function exportTable(tableName, options) {
const exportTable = function exportTable(tableName, options) {//单表导出函数
if (TABLES_ALLOWLIST.includes(tableName) ||
(options.include && _.isArray(options.include) && options.include.indexOf(tableName) !== -1)) {
const query = (options.transacting || db.knex)(tableName);
@ -24,14 +24,14 @@ const exportTable = function exportTable(tableName, options) {
}
};
const getSettingsTableData = function getSettingsTableData(settingsData) {
const getSettingsTableData = function getSettingsTableData(settingsData) { //数据过滤函数,移除黑名单中的设置项
return settingsData && settingsData.filter((setting) => {
return !SETTING_KEYS_BLOCKLIST.includes(setting.key);
});
};
const doExport = async function doExport(options) {
options = options || {include: []};
const doExport = async function doExport(options) {//导出主函数
options = options || {include: []};//默认选项,包含所有表
try {
const tables = await commands.getTables(options.transacting);
@ -41,7 +41,7 @@ const doExport = async function doExport(options) {
}));
const exportData = {
meta: {
meta: { //导出元数据包含导出时间和Ghost版本
exported_on: new Date().getTime(),
version: ghostVersion.full
},

@ -1,4 +1,5 @@
// NOTE: these tables can be optionally included to have full db-like export
//比较重要的表,需要导出进行操作
const BACKUP_TABLES = [
'actions',
'api_keys',

@ -4,7 +4,7 @@ const config = require('../../../../shared/config');
const urlUtils = require('../../../../shared/url-utils');
const storage = require('../../../adapters/storage');
let ImageHandler;
//各种类型文本的导入处理程序
ImageHandler = {
type: 'images',
extensions: config.get('uploads').images.extensions,

@ -52,10 +52,12 @@ let defaults = {
contentTypes: ['application/zip', 'application/x-zip-compressed'],
directories: []
};
/* Ghost
* 负责导入图片媒体文件内容文件Revue数据JSON数据Markdown数据等
*/
class ImportManager {
constructor() {
const mediaHandler = new ImporterContentFileHandler({
const mediaHandler = new ImporterContentFileHandler({//媒体文件导入处理程序
type: 'media',
// @NOTE: making the second parameter strict folder "content/media" brakes the glob pattern
// in the importer, so we need to keep it as general "content" unless
@ -69,7 +71,7 @@ class ImportManager {
storage: mediaStorage
});
const filesHandler = new ImporterContentFileHandler({
const filesHandler = new ImporterContentFileHandler({//文件导入处理程序
type: 'files',
// @NOTE: making the second parameter strict folder "content/files" brakes the glob pattern
// in the importer, so we need to keep it as general "content" unless
@ -82,12 +84,12 @@ class ImportManager {
urlUtils: urlUtils,
storage: fileStorage
});
const imageImporter = new ContentFileImporter({
//导入器初始化
const imageImporter = new ContentFileImporter({
type: 'images',
store: imageStorage
});
const mediaImporter = new ContentFileImporter({
const mediaImporter = new ContentFileImporter({
type: 'media',
store: mediaStorage
});
@ -98,25 +100,25 @@ class ImportManager {
});
/**
* @type {Importer[]} importers
* @type {Importer[]} importers 导入器数组包含图片导入器媒体文件导入器内容文件导入器Revue导入器和数据导入器
*/
this.importers = [imageImporter, mediaImporter, contentFilesImporter, RevueImporter, DataImporter];
/**
* @type {Handler[]}
* @type {Handler[]} handlers 处理程序数组包含图片处理程序媒体文件处理程序文件处理程序Revue处理程序和JSON处理程序
*/
this.handlers = [ImageHandler, mediaHandler, filesHandler, RevueHandler, JSONHandler, MarkdownHandler];
// Keep track of file to cleanup at the end
/**
* @type {?string}
* @type {?string} fileToDelete 待删除的文件路径初始值为null
*/
this.fileToDelete = null;
}
/**
* Get an array of all the file extensions for which we have handlers
* @returns {string[]}
* @returns {string[]} extensions 所有支持的文件扩展名数组
*/
getExtensions() {
return _.union(_.flatMap(this.handlers, 'extensions'), defaults.extensions);
@ -124,7 +126,7 @@ class ImportManager {
/**
* Get an array of all the mime types for which we have handlers
* @returns {string[]}
* @returns {string[]} contentTypes 所有支持的文件MIME类型数组
*/
getContentTypes() {
return _.union(_.flatMap(this.handlers, 'contentTypes'), defaults.contentTypes);
@ -132,7 +134,7 @@ class ImportManager {
/**
* Get an array of directories for which we have handlers
* @returns {string[]}
* @returns {string[]} directories 所有支持的文件目录数组
*/
getDirectories() {
return _.union(_.flatMap(this.handlers, 'directories'), defaults.directories);
@ -140,8 +142,8 @@ class ImportManager {
/**
* Convert items into a glob string
* @param {String[]} items
* @returns {String}
* @param {String[]} items 要转换的文件扩展名数组
* @returns {String} globPattern 转换后的文件扩展名glob模式字符串
*/
getGlobPattern(items) {
return '+(' + _.reduce(items, function (memo, ext) {
@ -150,9 +152,9 @@ class ImportManager {
}
/**
* @param {String[]} extensions
* @param {Number} [level]
* @returns {String}
* @param {String[]} extensions 要匹配的文件扩展名数组
* @param {Number} [level=ROOT_OR_SINGLE_DIR] 匹配级别默认值为ROOT_OR_SINGLE_DIR
* @returns {String} globPattern 转换后的文件扩展名glob模式字符串
*/
getExtensionGlob(extensions, level) {
const prefix = level === ALL_DIRS ? '**/*' :
@ -163,9 +165,9 @@ class ImportManager {
/**
*
* @param {String[]} directories
* @param {Number} [level]
* @returns {String}
* @param {String[]} directories 要匹配的文件目录数组
* @param {Number} [level=ROOT_OR_SINGLE_DIR] 匹配级别默认值为ROOT_OR_SINGLE_DIR
* @returns {String} globPattern 转换后的文件目录glob模式字符串
*/
getDirectoryGlob(directories, level) {
const prefix = level === ALL_DIRS ? '**/' :

@ -1,73 +1,102 @@
const _ = require('lodash');
const logging = require('@tryghost/logging');
const errors = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const db = require('../db');
const DatabaseInfo = require('@tryghost/database-info');
const schema = require('./schema');
const messages = {
//这里是基本的外部链接,让之后的一些操作可以直接进行使用
const _ = require('lodash'); // 工具库
const logging = require('@tryghost/logging'); // 日志
const errors = require('@tryghost/errors'); // 错误处理
const tpl = require('@tryghost/tpl'); // 模板
const db = require('../db'); // 数据库连接
const DatabaseInfo = require('@tryghost/database-info'); // 数据库信息
const schema = require('./schema'); // 数据库模型里面各种表的定义
const messages = { //相关的错误提示
hasPrimaryKeySQLiteError: 'Must use hasPrimaryKeySQLite on an SQLite3 database',
hasForeignSQLite3: 'Must use hasForeignSQLite3 on an SQLite3 database',
noSupportForDatabase: 'No support for database client {client}'
};
//这个地方都是实现sql语句功能的函数
/**
* @param {string} tableName
* @param {import('knex').knex.TableBuilder} tableBuilder
* @param {string} columnName
* @param {object} [columnSpec]
* 根据schema.js中的定义来创建数据库中的表
*
* @param {string} tableName - 表名用于从 schema.js 中获取字段定义
* @param {Object} tableBuilder - Knex.js 的表构建器对象用于创建字段
* @param {string} columnName - 字段名
* @param {Object} [columnSpec] - 字段定义对象如果未提供则从 schema.js 中获取
* @returns {void}
*/
function addTableColumn(tableName, tableBuilder, columnName, columnSpec = schema[tableName][columnName]) {
let column;
let column; // Knex.js 字段构建器对象,用于链式调用字段约束方法
// creation distinguishes between text with fieldtype, string with maxlength and all others
// 字段类型处理:区分 text带 fieldtype、string带 maxlength和其他基本类型
if (columnSpec.type === 'text' && Object.prototype.hasOwnProperty.call(columnSpec, 'fieldtype')) {
// 处理 text 类型,支持指定 fieldtype如 'text', 'longtext' 等)
column = tableBuilder[columnSpec.type](columnName, columnSpec.fieldtype);
} else if (columnSpec.type === 'string') {
// 处理 string 类型,支持指定最大长度
if (Object.prototype.hasOwnProperty.call(columnSpec, 'maxlength')) {
column = tableBuilder[columnSpec.type](columnName, columnSpec.maxlength);
} else {
// 默认使用 191 作为字符串长度(兼容 MySQL 的索引限制)
column = tableBuilder[columnSpec.type](columnName, 191);
}
} else {
// 处理其他基本类型integer, boolean, dateTime 等)
column = tableBuilder[columnSpec.type](columnName);
}
// === 字段约束处理 ===
// 空值约束:控制字段是否允许 NULL 值
if (Object.prototype.hasOwnProperty.call(columnSpec, 'nullable') && columnSpec.nullable === true) {
column.nullable();
column.nullable(); // 允许 NULL 值
} else {
column.nullable(false);
column.nullable(false); // 不允许 NULL 值(默认)
}
// 主键约束:将字段设置为主键
if (Object.prototype.hasOwnProperty.call(columnSpec, 'primary') && columnSpec.primary === true) {
column.primary();
}
// 唯一约束:确保字段值在表中唯一
if (Object.prototype.hasOwnProperty.call(columnSpec, 'unique') && columnSpec.unique) {
column.unique();
}
// 无符号约束:适用于整数类型,确保值为非负数
if (Object.prototype.hasOwnProperty.call(columnSpec, 'unsigned') && columnSpec.unsigned) {
column.unsigned();
}
// 外键引用:建立字段与其他表的关联
if (Object.prototype.hasOwnProperty.call(columnSpec, 'references')) {
// check if table exists?
// 注意:这里没有检查被引用的表是否存在,需要在调用前确保
column.references(columnSpec.references);
}
// 外键约束名:为外键约束指定名称
if (Object.prototype.hasOwnProperty.call(columnSpec, 'constraintName')) {
column.withKeyName(columnSpec.constraintName);
}
// 级联删除策略:控制关联记录删除时的行为
if (Object.prototype.hasOwnProperty.call(columnSpec, 'cascadeDelete') && columnSpec.cascadeDelete === true) {
column.onDelete('CASCADE');
column.onDelete('CASCADE'); // 级联删除:删除主记录时自动删除关联记录
} else if (Object.prototype.hasOwnProperty.call(columnSpec, 'setNullDelete') && columnSpec.setNullDelete === true) {
column.onDelete('SET NULL');
column.onDelete('SET NULL'); // 设为 NULL删除主记录时将外键设为 NULL
}
// 默认值:为字段设置默认值
if (Object.prototype.hasOwnProperty.call(columnSpec, 'defaultTo')) {
column.defaultTo(columnSpec.defaultTo);
}
// 索引:为字段创建索引以提高查询性能
if (Object.prototype.hasOwnProperty.call(columnSpec, 'index') && columnSpec.index === true) {
column.index();
}
}
}
//用函数将sql语句的功能进行封装方便之后使用
/**
* @param {string} tableName
@ -285,7 +314,7 @@ async function dropUnique(tableName, columns, transaction = db.knex) {
/**
* Checks if a foreign key exists in a table over the given columns.
*
*
* @param {Object} configuration - contains all configuration for this function
* @param {string} configuration.fromTable - name of the table to add the foreign key to
* @param {string} configuration.fromColumn - column of the table to add the foreign key to
@ -553,8 +582,28 @@ async function getColumns(table, transaction = db.knex) {
return Promise.reject(tpl(messages.noSupportForDatabase, {client: client}));
}
/**
* 创建字段迁移脚本的高阶函数
*
* 这是一个高级的数据库迁移工具函数用于创建幂等的字段迁移脚本
* 通过检查数据库当前状态确保迁移操作只在必要时执行避免重复操作
*
*/
function createColumnMigration(...migrations) {
/**
* 执行单个迁移操作的内部函数
*
* @param {import('knex').Knex} conn - 数据库连接对象
* @param {Object} migration - 迁移配置对象
* @param {string} migration.table - 表名
* @param {string} migration.column - 字段名
* @param {Function} migration.dbIsInCorrectState - 状态检查函数返回布尔值
* @param {Function} migration.operation - 要执行的操作函数 addColumndropColumn
* @param {string} migration.operationVerb - 操作动词用于日志记录
* @param {Object} [migration.columnDefinition] - 字段定义仅用于添加字段操作
*/
async function runColumnMigration(conn, migration) {
// 解构迁移配置参数
const {
table,
column,
@ -564,44 +613,70 @@ function createColumnMigration(...migrations) {
columnDefinition
} = migration;
// 检查字段是否存在
const hasColumn = await conn.schema.hasColumn(table, column);
// 根据状态检查函数判断是否需要执行操作
const isInCorrectState = dbIsInCorrectState(hasColumn);
if (isInCorrectState) {
// 如果数据库已处于正确状态,跳过操作并记录警告日志
logging.warn(`${operationVerb} ${table}.${column} column - skipping as table is correct`);
} else {
// 如果数据库需要更新,执行操作并记录信息日志
logging.info(`${operationVerb} ${table}.${column} column`);
await operation(table, column, conn, columnDefinition);
}
}
/**
* 返回的迁移执行函数
*
* @param {import('knex').Knex} conn - 数据库连接对象
* @returns {Promise} 异步执行所有迁移操作
*/
return async function columnMigration(conn) {
// 按顺序执行所有迁移配置
for (const migration of migrations) {
await runColumnMigration(conn, migration);
}
};
}
/**
* 数据库模式操作命令模块导出
* 这个地方是对上面封装sql语句的函数进行一个成列
*/
module.exports = {
createTable,
deleteTable,
getTables,
getIndexes,
addUnique,
dropUnique,
addIndex,
dropIndex,
addPrimaryKey,
addForeign,
dropForeign,
addColumn,
renameColumn,
dropColumn,
setNullable,
dropNullable,
getColumns,
createColumnMigration,
// 表操作命令
createTable, // 创建表:根据 schema.js 定义创建新表
deleteTable, // 删除表:删除指定的表
// 元数据查询命令
getTables, // 获取表列表:查询数据库中所有表名
getIndexes, // 获取索引:查询指定表的所有索引
getColumns, // 获取字段:查询指定表的所有字段名
// 约束操作命令
addUnique, // 添加唯一约束:为字段添加唯一性约束
dropUnique, // 删除唯一约束:移除字段的唯一性约束
addIndex, // 添加索引:为字段创建索引以提高查询性能
dropIndex, // 删除索引:移除字段的索引
addPrimaryKey, // 添加主键:将字段设置为主键
addForeign, // 添加外键:建立表之间的关联关系
dropForeign, // 删除外键:移除表之间的关联关系
// 字段操作命令
addColumn, // 添加字段:向表中添加新字段
renameColumn, // 重命名字段:修改字段名称
dropColumn, // 删除字段:从表中移除字段
setNullable, // 设为可空:允许字段为 NULL 值
dropNullable, // 设为非空:不允许字段为 NULL 值
// 高级功能
createColumnMigration, // 创建字段迁移:生成字段变更的迁移脚本
// 测试专用函数(仅供内部测试使用)
// NOTE: below are exposed for testing purposes only
_hasForeignSQLite: hasForeignSQLite,
_hasPrimaryKeySQLite: hasPrimaryKeySQLite
};
_hasForeignSQLite: hasForeignSQLite, // 检查 SQLite 外键存在性(测试用)
_hasPrimaryKeySQLite: hasPrimaryKeySQLite // 检查 SQLite 主键存在性(测试用)
};

@ -4,3 +4,4 @@ const defaultSettingsPath = config.get('paths').defaultSettings;
const defaultSettings = require(defaultSettingsPath);
module.exports = defaultSettings;
//一些默认配置文件

@ -1,13 +1,13 @@
const _ = require('lodash');
const _ = require('lodash'); //引入lodash库用于处理数组、对象等数据结构
const logging = require('@tryghost/logging');
const {sequence} = require('@tryghost/promise');
const models = require('../../../models');
const baseUtils = require('../../../models/base/utils');
const moment = require('moment');
class FixtureManager {
const moment = require('moment');
//把写在 JSON/JS 里的“蓝图”变成真正的数据库记录,并建立好它们之间的关联。
class FixtureManager {
/**
* Create a new FixtureManager instance
*
@ -194,22 +194,23 @@ class FixtureManager {
* @param {String} objName
* @returns {Object} fixture relation
*/
/**
* 查找特定对象的权限关系
*
* 用于设置角色权限管理员对文章有所有权限
*/
findPermissionRelationsForObject(objName, role) {
// Make a copy and delete any entries we don't want
const foundRelation = _.cloneDeep(this.findRelationFixture('Role', 'Permission'));
const foundRelation = this.findRelationFixture('Role', 'Permission');
// 过滤只保留指定对象的权限
_.each(foundRelation.entries, (entry, key) => {
_.each(entry, (perm, obj) => {
if (obj !== objName) {
delete entry[obj];
delete entry[obj]; // 移除其他对象权限
}
});
if (_.isEmpty(entry) || (role && role !== key)) {
delete foundRelation.entries[key];
}
});
return foundRelation;
}
@ -382,52 +383,49 @@ class FixtureManager {
* @param {{from, to, entries}} relationFixture
* @returns {Promise<any>}
*/
/**
* 创建模型之间的关联关系
*
* 处理多对多一对多等复杂关系
* 避免重复关联检查是否已存在相同关系
*
* @param {{from, to, entries}} relationFixture - 关系配置
*/
async addFixturesForRelation(relationFixture, options) {
const ops = [];
let max = 0;
// 获取关联双方的现有数据
const data = await this.fetchRelationData(relationFixture, options);
_.each(relationFixture.entries, (entry, key) => {
const fromItem = data.from.find(FixtureManager.matchFunc(relationFixture.from.match, key));
// CASE: You add new fixtures e.g. a new role in a new release.
// As soon as an **older** migration script wants to add permissions for any resource, it iterates over the
// permissions for each role. But if the role does not exist yet, it won't find the matching db entry and breaks.
if (!fromItem) {
logging.warn('Skip: Target database entry not found for key: ' + key);
return Promise.resolve();
}
// 查找源模型
const fromItem = data.from.find(
FixtureManager.matchFunc(relationFixture.from.match, key)
);
_.each(entry, (value, entryKey) => {
let toItems = data.to.filter(FixtureManager.matchFunc(relationFixture.to.match, entryKey, value));
max += toItems.length;
// Remove any duplicates that already exist in the collection
// 查找目标模型
let toItems = data.to.filter(
FixtureManager.matchFunc(relationFixture.to.match, entryKey, value)
);
// 移除已存在的关联(避免重复)
toItems = _.reject(toItems, (item) => {
return fromItem
.related(relationFixture.from.relation)
.find((model) => {
const objectToMatch = FixtureManager.matchObj(relationFixture.to.match, item);
return Object.keys(objectToMatch).every((keyToCheck) => {
return model.get(keyToCheck) === objectToMatch[keyToCheck];
});
});
return fromItem.related(relationFixture.from.relation)
.find((model) => /* 检查是否已关联 */);
});
if (toItems && toItems.length > 0) {
ops.push(function addRelationItems() {
return baseUtils.attach(
models[relationFixture.from.Model || relationFixture.from.model],
fromItem.id,
relationFixture.from.relation,
toItems,
options
);
});
// 创建新关联
if (toItems.length > 0) {
ops.push(() => baseUtils.attach(
models[relationFixture.from.model],
fromItem.id,
relationFixture.from.relation,
toItems,
options
));
}
});
});
}
const result = await sequence(ops);
return {expected: max, done: _(result).map('length').sum()};
@ -472,4 +470,4 @@ class FixtureManager {
}
}
module.exports = FixtureManager;
module.exports = FixtureManager;

@ -8,140 +8,153 @@
* Text = length 65535 (64 KiB)
* Long text = length 1,000,000,000
*/
/*
* primary 主键
* unique 唯一
* defaultTo 默认值
* nullable 是否为空
* validations 验证规则
* isIn 枚举值defaultTo为当前枚举值中的一个
* isUUID 是否为UUID
* validations 验证规则看当前字段是否符合规则不符合就拒写
* visibility 可见性验证仅给定人员可见
* slug:url 别名 type:内容类型post,page)
*/
module.exports = {
newsletters: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
uuid: {type: 'string', maxlength: 36, nullable: false, unique: true, validations: {isUUID: true}},
name: {type: 'string', maxlength: 191, nullable: false, unique: true},
description: {type: 'string', maxlength: 2000, nullable: true},
feedback_enabled: {type: 'boolean', nullable: false, defaultTo: false},
slug: {type: 'string', maxlength: 191, nullable: false, unique: true},
sender_name: {type: 'string', maxlength: 191, nullable: true},
sender_email: {type: 'string', maxlength: 191, nullable: true},
sender_reply_to: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'newsletter'},
status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'active', validations: {isIn: [['active', 'archived']]}},
newsletters: { // 邮件通讯表 - 存储邮件通讯相关的配置信息
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, // 内部主键(对外不可见)
uuid: {type: 'string', maxlength: 36, nullable: false, unique: true, validations: {isUUID: true}}, // 外部唯一标志即使被泄露还有id保护无法被外部人员窃取相关数据
name: {type: 'string', maxlength: 191, nullable: false, unique: true}, // 邮件通讯名称,必须唯一
description: {type: 'string', maxlength: 2000, nullable: true}, // 邮件通讯描述,可为空
feedback_enabled: {type: 'boolean', nullable: false, defaultTo: false}, // 是否启用反馈功能,默认关闭
slug: {type: 'string', maxlength: 191, nullable: false, unique: true}, // URL友好的标识符必须唯一
sender_name: {type: 'string', maxlength: 191, nullable: true}, // 发件人名称,可为空
sender_email: {type: 'string', maxlength: 191, nullable: true}, // 发件人邮箱地址,可为空
sender_reply_to: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'newsletter'}, // 回复地址,默认为'newsletter'
status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'active', validations: {isIn: [['active', 'archived']]}}, // 状态:活跃或已归档
visibility: {
type: 'string',
maxlength: 50,
nullable: false,
defaultTo: 'members'
defaultTo: 'members'//设置可见性,仅会员可见
},
subscribe_on_signup: {type: 'boolean', nullable: false, defaultTo: true},
sort_order: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0},
header_image: {type: 'string', maxlength: 2000, nullable: true},
show_header_icon: {type: 'boolean', nullable: false, defaultTo: true},
show_header_title: {type: 'boolean', nullable: false, defaultTo: true},
show_excerpt: {type: 'boolean', nullable: false, defaultTo: false},
title_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}},
title_alignment: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'center', validations: {isIn: [['center', 'left']]}},
show_feature_image: {type: 'boolean', nullable: false, defaultTo: true},
body_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}},
footer_content: {type: 'text', maxlength: 1000000000, nullable: true},
show_badge: {type: 'boolean', nullable: false, defaultTo: true},
show_header_name: {type: 'boolean', nullable: false, defaultTo: true},
show_post_title_section: {type: 'boolean', nullable: false, defaultTo: true},
show_comment_cta: {type: 'boolean', nullable: false, defaultTo: true},
show_subscription_details: {type: 'boolean', nullable: false, defaultTo: false},
show_latest_posts: {type: 'boolean', nullable: false, defaultTo: false},
background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'light'},
post_title_color: {type: 'string', maxlength: 50, nullable: true},
created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true},
button_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'rounded', validations: {isIn: [['square', 'rounded', 'pill']]}},
button_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'fill', validations: {isIn: [['fill', 'outline']]}},
title_font_weight: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'bold', validations: {isIn: [['normal', 'medium', 'semibold', 'bold']]}},
link_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'underline', validations: {isIn: [['underline', 'regular', 'bold']]}},
image_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'square', validations: {isIn: [['square', 'rounded']]}},
header_background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'transparent'},
section_title_color: {type: 'string', maxlength: 50, nullable: true},
divider_color: {type: 'string', maxlength: 50, nullable: true},
button_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'},
link_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}
},
subscribe_on_signup: {type: 'boolean', nullable: false, defaultTo: true}, // 用户注册时是否自动订阅邮件通讯默认为true自动订阅
sort_order: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0}, // 排序顺序,用于控制显示顺序
header_image: {type: 'string', maxlength: 2000, nullable: true}, // 头部图片URL可为空
show_header_icon: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示头部图标,默认显示
show_header_title: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示头部标题,默认显示
show_excerpt: {type: 'boolean', nullable: false, defaultTo: false}, // 是否显示摘要,默认不显示
title_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}}, // 标题字体类别:衬线体或无衬线体
title_alignment: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'center', validations: {isIn: [['center', 'left']]}}, // 标题对齐方式:居中或左对齐
show_feature_image: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示特色图片,默认显示
body_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}}, // 正文字体类别:衬线体或无衬线体
footer_content: {type: 'text', maxlength: 1000000000, nullable: true}, // 页脚内容,支持长文本
show_badge: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示徽章,默认显示
show_header_name: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示头部名称,默认显示
show_post_title_section: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示文章标题区域,默认显示
show_comment_cta: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示评论行动号召,默认显示
show_subscription_details: {type: 'boolean', nullable: false, defaultTo: false}, // 是否显示订阅详情,默认不显示
show_latest_posts: {type: 'boolean', nullable: false, defaultTo: false}, // 是否显示最新文章,默认不显示
background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'light'}, // 背景颜色,默认为浅色
post_title_color: {type: 'string', maxlength: 50, nullable: true}, // 文章标题颜色,可为空
created_at: {type: 'dateTime', nullable: false}, // 创建时间,不能为空
updated_at: {type: 'dateTime', nullable: true}, // 更新时间,可为空
button_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'rounded', validations: {isIn: [['square', 'rounded', 'pill']]}}, // 按钮圆角样式:方形、圆角或药丸形
button_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'fill', validations: {isIn: [['fill', 'outline']]}}, // 按钮样式:填充或轮廓
title_font_weight: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'bold', validations: {isIn: [['normal', 'medium', 'semibold', 'bold']]}}, // 标题字体粗细:正常、中等、半粗体、粗体
link_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'underline', validations: {isIn: [['underline', 'regular', 'bold']]}}, // 链接样式:下划线、常规、粗体
image_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'square', validations: {isIn: [['square', 'rounded']]}}, // 图片圆角样式:方形或圆角
header_background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'transparent'}, // 头部背景颜色,默认为透明
section_title_color: {type: 'string', maxlength: 50, nullable: true}, // 区域标题颜色,可为空
divider_color: {type: 'string', maxlength: 50, nullable: true}, // 分隔线颜色,可为空
button_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}, // 按钮颜色,默认为强调色
link_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'} // 链接颜色,默认为强调色
},
// 内容管理器,对内容的各种信息进行存储管理
posts: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
uuid: {type: 'string', maxlength: 36, nullable: false, index: true, validations: {isUUID: true}},
title: {type: 'string', maxlength: 2000, nullable: false, validations: {isLength: {max: 255}}},
slug: {type: 'string', maxlength: 191, nullable: false},
mobiledoc: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true},
lexical: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true},
html: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true},
comment_id: {type: 'string', maxlength: 50, nullable: true},
plaintext: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true},
feature_image: {type: 'string', maxlength: 2000, nullable: true},
featured: {type: 'boolean', nullable: false, defaultTo: false},
type: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'post', validations: {isIn: [['post', 'page']]}},
status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'draft', validations: {isIn: [['published', 'draft', 'scheduled', 'sent']]}},
id: {type: 'string', maxlength: 24, nullable: false, primary: true}, // 主键ID唯一标识文章
uuid: {type: 'string', maxlength: 36, nullable: false, index: true, validations: {isUUID: true}}, // 全局唯一标识符,用于外部引用
title: {type: 'string', maxlength: 2000, nullable: false, validations: {isLength: {max: 255}}}, // 文章标题最大长度255字符
slug: {type: 'string', maxlength: 191, nullable: false, unique: true}, // URL别名用于在URL中标识文章
mobiledoc: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, // 旧编辑器JSON格式内容
lexical: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, // 新编辑器JSON格式内容
html: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, // 编译之后的HTML内容
comment_id: {type: 'string', maxlength: 50, nullable: true}, // 评论ID用于外部评论系统集成
plaintext: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, // 纯文本摘要,用于搜索和预览
feature_image: {type: 'string', maxlength: 2000, nullable: true}, // 封面图地址
featured: {type: 'boolean', nullable: false, defaultTo: false}, // 是否置顶,默认为否,需要手动设置
type: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'post', validations: {isIn: [['post', 'page']]}}, // 内容类型post为文章page为页面
status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'draft', validations: {isIn: [['published', 'draft', 'scheduled', 'sent']]}}, // 文章状态:已发布、草稿、定时发布、已发送
// NOTE: unused at the moment and reserved for future features
locale: {type: 'string', maxlength: 6, nullable: true},
locale: {type: 'string', maxlength: 6, nullable: true}, // 语言区域设置,预留字段
visibility: {
type: 'string',
maxlength: 50,
nullable: false,
defaultTo: 'public'
defaultTo: 'public' // 可见性设置,默认为公开
},
email_recipient_filter: {
type: 'text',
maxlength: 1000000000,
nullable: false
nullable: false // 邮件接收者筛选条件
},
created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true, index: true},
published_at: {type: 'dateTime', nullable: true, index: true},
published_by: {type: 'string', maxlength: 24, nullable: true},
custom_excerpt: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 300}}},
codeinjection_head: {type: 'text', maxlength: 65535, nullable: true},
codeinjection_foot: {type: 'text', maxlength: 65535, nullable: true},
custom_template: {type: 'string', maxlength: 100, nullable: true},
canonical_url: {type: 'text', maxlength: 2000, nullable: true},
newsletter_id: {type: 'string', maxlength: 24, nullable: true, references: 'newsletters.id'},
show_title_and_feature_image: {type: 'boolean', nullable: false, defaultTo: true},
created_at: {type: 'dateTime', nullable: false}, // 创建时间
updated_at: {type: 'dateTime', nullable: true, index: true}, // 更新时间,有索引便于排序
published_at: {type: 'dateTime', nullable: true, index: true}, // 发布时间,有索引便于排序
published_by: {type: 'string', maxlength: 24, nullable: true}, // 发布者ID
custom_excerpt: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 300}}}, // 自定义摘要最大300字符
codeinjection_head: {type: 'text', maxlength: 65535, nullable: true}, // 头部代码注入,用于在<head>标签中添加自定义代码
codeinjection_foot: {type: 'text', maxlength: 65535, nullable: true}, // 底部代码注入,用于在<body>结束前添加自定义代码
custom_template: {type: 'string', maxlength: 100, nullable: true}, // 自定义模板名称
canonical_url: {type: 'text', maxlength: 2000, nullable: true}, // 规范URL用于SEO避免重复内容
newsletter_id: {type: 'string', maxlength: 24, nullable: true, references: 'newsletters.id'}, // 关联的邮件通讯ID
show_title_and_feature_image: {type: 'boolean', nullable: false, defaultTo: true}, // 是否显示标题和封面图,默认为是
'@@INDEXES@@': [
['type','status','updated_at']
['type','status','updated_at'] // 复合索引:按类型、状态、更新时间排序,提高查询性能
],
'@@UNIQUE_CONSTRAINTS@@': [
['slug', 'type']
['slug', 'type'] // 唯一约束同一类型的文章不能有相同的slug
]
},
posts_meta: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
post_id: {type: 'string', maxlength: 24, nullable: false, references: 'posts.id', unique: true},
og_image: {type: 'string', maxlength: 2000, nullable: true},
og_title: {type: 'string', maxlength: 300, nullable: true},
og_description: {type: 'string', maxlength: 500, nullable: true},
twitter_image: {type: 'string', maxlength: 2000, nullable: true},
twitter_title: {type: 'string', maxlength: 300, nullable: true},
twitter_description: {type: 'string', maxlength: 500, nullable: true},
meta_title: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 300}}},
meta_description: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 500}}},
email_subject: {type: 'string', maxlength: 300, nullable: true},
frontmatter: {type: 'text', maxlength: 65535, nullable: true},
feature_image_alt: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 191}}},
feature_image_caption: {type: 'text', maxlength: 65535, nullable: true},
email_only: {type: 'boolean', nullable: false, defaultTo: false}
posts_meta: { //用于存储文章的额外元数据
id: {type: 'string', maxlength: 24, nullable: false, primary: true},//主键ID唯一标识文章元数据
post_id: {type: 'string', maxlength: 24, nullable: false, references: 'posts.id', unique: true}, // 关联的文章ID唯一标识
og_image: {type: 'string', maxlength: 2000, nullable: true}, // Open Graph 图片URL
og_title: {type: 'string', maxlength: 300, nullable: true}, // Open Graph 标题最大300字符
og_description: {type: 'string', maxlength: 500, nullable: true}, // Open Graph 描述最大500字符
twitter_image: {type: 'string', maxlength: 2000, nullable: true}, // Twitter 图片URL
twitter_title: {type: 'string', maxlength: 300, nullable: true}, // Twitter 标题最大300字符
twitter_description: {type: 'string', maxlength: 500, nullable: true}, // Twitter 描述最大500字符
meta_title: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 300}}},// SEO 标题最大300字符
meta_description: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 500}}},// SEO 描述最大500字符
email_subject: {type: 'string', maxlength: 300, nullable: true},// 邮件主题最大300字符
frontmatter: {type: 'text', maxlength: 65535, nullable: true},// 文章的Frontmatter内容用于自定义元数据
feature_image_alt: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 191}}},// 封面图替代文本最大191字符
feature_image_caption: {type: 'text', maxlength: 65535, nullable: true},// 封面图标题,用于图片描述
email_only: {type: 'boolean', nullable: false, defaultTo: false}, // 是否仅通过邮件发送,默认为否
},
// NOTE: this is the staff table
users: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
name: {type: 'string', maxlength: 191, nullable: false},
slug: {type: 'string', maxlength: 191, nullable: false, unique: true},
password: {type: 'string', maxlength: 60, nullable: false},
email: {type: 'string', maxlength: 191, nullable: false, unique: true, validations: {isEmail: true}},
profile_image: {type: 'string', maxlength: 2000, nullable: true},
cover_image: {type: 'string', maxlength: 2000, nullable: true},
bio: {type: 'text', maxlength: 65535, nullable: true, validations: {isLength: {max: 250}}},
website: {type: 'string', maxlength: 2000, nullable: true, validations: {isEmptyOrURL: true}},
location: {type: 'text', maxlength: 65535, nullable: true, validations: {isLength: {max: 150}}},
facebook: {type: 'string', maxlength: 2000, nullable: true},
id: {type: 'string', maxlength: 24, nullable: false, primary: true},//主键ID唯一标识用户
name: {type: 'string', maxlength: 191, nullable: false}, // 用户名
slug: {type: 'string', maxlength: 191, nullable: false, unique: true},//用户slug唯一标识
password: {type: 'string', maxlength: 60, nullable: false},//用户密码
email: {type: 'string', maxlength: 191, nullable: false, unique: true, validations: {isEmail: true}},//用户邮箱,唯一标识
profile_image: {type: 'string', maxlength: 2000, nullable: true},// 用户头像URL
cover_image: {type: 'string', maxlength: 2000, nullable: true},//用户封面图URL
bio: {type: 'text', maxlength: 65535, nullable: true, validations: {isLength: {max: 250}}},//用户简介最大250字符
website: {type: 'string', maxlength: 2000, nullable: true, validations: {isEmptyOrURL: true}},//用户网站URL
location: {type: 'text', maxlength: 65535, nullable: true, validations: {isLength: {max: 150}}},//用户位置最大150字符
facebook: {type: 'string', maxlength: 2000, nullable: true},//用户Facebook链接
twitter: {type: 'string', maxlength: 2000, nullable: true},
threads: {type: 'string', maxlength: 191, nullable: true},
bluesky: {type: 'string', maxlength: 191, nullable: true},
mastodon: {type: 'string', maxlength: 191, nullable: true},
tiktok: {type: 'string', maxlength: 191, nullable: true},
youtube: {type: 'string', maxlength: 191, nullable: true},
instagram: {type: 'string', maxlength: 191, nullable: true},
linkedin: {type: 'string', maxlength: 191, nullable: true},
accessibility: {type: 'text', maxlength: 65535, nullable: true},
threads: {type: 'string', maxlength: 191, nullable: true},//用户Threads链接
bluesky: {type: 'string', maxlength: 191, nullable: true},//用户Bluesky链接
mastodon: {type: 'string', maxlength: 191, nullable: true},//用户Mastodon链接
tiktok: {type: 'string', maxlength: 191, nullable: true},//用户TikTok链接
youtube: {type: 'string', maxlength: 191, nullable: true}, //用户YouTube链接
instagram: {type: 'string', maxlength: 191, nullable: true},//用户Instagram链接
linkedin: {type: 'string', maxlength: 191, nullable: true},//用户LinkedIn链接
accessibility: {type: 'text', maxlength: 65535, nullable: true},//用户可访问性设置
status: {
type: 'string',
maxlength: 50,
@ -160,7 +173,7 @@ module.exports = {
}
},
// NOTE: unused at the moment and reserved for future features
locale: {type: 'string', maxlength: 6, nullable: true},
locale: {type: 'string', maxlength: 6, nullable: true},//用户语言设置
visibility: {
type: 'string',
maxlength: 50,
@ -184,25 +197,25 @@ module.exports = {
created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true}
},
posts_authors: {
posts_authors: { // 文章作者关联表
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
post_id: {type: 'string', maxlength: 24, nullable: false, references: 'posts.id'},
author_id: {type: 'string', maxlength: 24, nullable: false, references: 'users.id'},
sort_order: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0}
},
roles: {
roles: {// 角色
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
name: {type: 'string', maxlength: 50, nullable: false, unique: true},
description: {type: 'string', maxlength: 2000, nullable: true},
created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true}
},
roles_users: {
roles_users: { // 角色用户关联表
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
role_id: {type: 'string', maxlength: 24, nullable: false},
user_id: {type: 'string', maxlength: 24, nullable: false}
},
permissions: {
permissions: {// 权限
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
name: {type: 'string', maxlength: 50, nullable: false, unique: true},
object_type: {type: 'string', maxlength: 50, nullable: false},
@ -211,17 +224,17 @@ module.exports = {
created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true}
},
permissions_users: {
permissions_users: {// 权限用户关联表
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
user_id: {type: 'string', maxlength: 24, nullable: false},
permission_id: {type: 'string', maxlength: 24, nullable: false}
},
permissions_roles: {
permissions_roles: {// 权限角色关联表
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
role_id: {type: 'string', maxlength: 24, nullable: false},
permission_id: {type: 'string', maxlength: 24, nullable: false}
},
settings: {
settings: {// 设置
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
group: {
type: 'string',
@ -265,7 +278,7 @@ module.exports = {
created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true}
},
tags: {
tags: {// 标签
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
name: {type: 'string', maxlength: 191, nullable: false, validations: {matches: /^([^,]|$)/}},
slug: {type: 'string', maxlength: 191, nullable: false, unique: true},
@ -294,7 +307,7 @@ module.exports = {
created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true}
},
posts_tags: {
posts_tags: {// 文章标签关联表
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
post_id: {type: 'string', maxlength: 24, nullable: false, references: 'posts.id'},
tag_id: {type: 'string', maxlength: 24, nullable: false, references: 'tags.id'},
@ -303,7 +316,7 @@ module.exports = {
['post_id','tag_id']
]
},
invites: {
invites: { // 邀请
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
role_id: {type: 'string', maxlength: 24, nullable: false},
status: {
@ -319,14 +332,14 @@ module.exports = {
created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true}
},
brute: {
brute: {// 暴力破解
key: {type: 'string', maxlength: 191, primary: true},
firstRequest: {type: 'bigInteger'},
lastRequest: {type: 'bigInteger'},
lifetime: {type: 'bigInteger'},
count: {type: 'integer'}
},
sessions: {
sessions: {// 会话
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
session_id: {type: 'string', maxlength: 32, nullable: false, unique: true},
user_id: {type: 'string', maxlength: 24, nullable: false},
@ -334,7 +347,7 @@ module.exports = {
created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true}
},
integrations: {
integrations: {// 集成
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
type: {
type: 'string',
@ -350,12 +363,12 @@ module.exports = {
created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true}
},
webhooks: {
webhooks: {//用于实现事件驱动的外部系统集成当系统中发生特定时间的时候会自动向配置的外部url发送http通知
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
event: {type: 'string', maxlength: 50, nullable: false, validations: {isLowercase: true}},
target_url: {type: 'string', maxlength: 2000, nullable: false},
name: {type: 'string', maxlength: 191, nullable: true},
secret: {type: 'string', maxlength: 191, nullable: true},
event: {type: 'string', maxlength: 50, nullable: false, validations: {isLowercase: true}},// 事件类型
target_url: {type: 'string', maxlength: 2000, nullable: false},// 目标url
name: {type: 'string', maxlength: 191, nullable: true},// 名称
secret: {type: 'string', maxlength: 191, nullable: true},// 密钥
// @NOTE: the defaultTo does not make sense to set on DB layer as it leads to unnecessary maintenance every major release
// would be ideal if we can remove the default and instead have "isIn" validation checking if it's a valid version e.g: 'v3', 'v4', 'canary'
api_version: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'v2'},
@ -376,14 +389,14 @@ module.exports = {
nullable: false,
validations: {isIn: [['content', 'admin']]}
},
secret: {
secret: { //密钥字符串
type: 'string',
maxlength: 191,
nullable: false,
unique: true,
unique: true,//防止出现重复
validations: {isLength: {min: 26, max: 128}}
},
role_id: {type: 'string', maxlength: 24, nullable: true},
role_id: {type: 'string', maxlength: 24, nullable: true}, // 角色ID关联到roles表定义密钥的权限级别
// integration_id is nullable to allow "internal" API keys that don't show in the UI
integration_id: {type: 'string', maxlength: 24, nullable: true},
user_id: {type: 'string', maxlength: 24, nullable: true},
@ -414,7 +427,7 @@ module.exports = {
feature_image_caption: {type: 'text', maxlength: 65535, nullable: true},
custom_excerpt: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 300}}}
},
members: {
members: {// 会员
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
uuid: {type: 'string', maxlength: 36, nullable: false, unique: true, validations: {isUUID: true}},
transient_id: {type: 'string', maxlength: 191, nullable: false, unique: true},
@ -437,7 +450,7 @@ module.exports = {
last_commented_at: {type: 'dateTime', nullable: true},
created_at: {type: 'dateTime', nullable: false},
updated_at: {type: 'dateTime', nullable: true},
'@@INDEXES@@': [
'@@INDEXES@@': [ // 索引
['email_disabled']
]
},
@ -1090,4 +1103,4 @@ module.exports = {
member_id: {type: 'string', maxlength: 24, nullable: true, references: 'members.id', unique: false, setNullDelete: true},
created_at: {type: 'dateTime', nullable: false}
}
};
};

@ -25,28 +25,38 @@ const messages = {
*
* ## on model add
* - validate everything to catch required fields
*/
/**
* 数据库模式验证函数 - 根据schema定义验证模型数据的完整性
*
* 这个函数负责验证传入的模型数据是否符合数据库表的schema定义
* 包括数据类型长度限制必填字段等约束条件如果不通过就统一抛错误防止数据污染
*/
function validateSchema(tableName, model, options) {
function validateSchema(tableName, model, options) {
options = options || {};
const columns = _.keys(schema[tableName]);
let validationErrors = [];
const columns = _.keys(schema[tableName]); // 获取表中所有列名
let validationErrors = []; // 存储验证错误
_.each(columns, function each(columnKey) {
_.each(columns, function each(columnKey) { // 遍历表中的每一列进行验证
let message = ''; // KEEP: Validator.js only validates strings.
// 将字段值转换为字符串进行验证Validator.js只验证字符串
const strVal = _.toString(model.get(columnKey));
// 如果是更新操作且字段未改变,则跳过验证(优化性能)
if (options.method !== 'insert' && !_.has(model.changed, columnKey)) {
return;
}
// check nullable
}
// ==================== 必填字段验证 ====================
// 检查非空约束字段不可为空、不是text类型、没有默认值
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'nullable') &&
schema[tableName][columnKey].nullable !== true &&
schema[tableName][columnKey].nullable !== true && // 字段不可为空
Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type') &&
schema[tableName][columnKey].type !== 'text' &&
!Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'defaultTo')
schema[tableName][columnKey].type !== 'text' && // 排除text类型允许空字符串
!Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'defaultTo') // 没有默认值
) {
// 检查字段值是否为空
if (validator.isEmpty(strVal)) {
message = tpl(messages.valueCannotBeBlank, {
tableName: tableName,
@ -54,14 +64,16 @@ function validateSchema(tableName, model, options) {
});
validationErrors.push(new errors.ValidationError({
message: message,
context: tableName + '.' + columnKey
context: tableName + '.' + columnKey // 错误上下文:表名.字段名
}));
}
}
// validate boolean columns
}
// ==================== 布尔字段验证 ====================
// 验证布尔类型字段
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type')
&& schema[tableName][columnKey].type === 'boolean') {
// 检查值是否为有效的布尔值或空值
if (!(validator.isBoolean(strVal) || validator.isEmpty(strVal))) {
message = tpl(messages.valueMustBeBoolean, {
tableName: tableName,
@ -73,15 +85,18 @@ function validateSchema(tableName, model, options) {
}));
}
// CASE: ensure we transform 0|1 to false|true
// CASE: 确保将0|1转换为false|true数据标准化
if (!validator.isEmpty(strVal)) {
model.set(columnKey, !!model.get(columnKey));
model.set(columnKey, !!model.get(columnKey)); // 强制转换为布尔值
}
}
// TODO: check if mandatory values should be enforced
// TODO: 检查是否应该强制执行必填值
// 当字段值不为null或undefined时进行进一步验证
if (model.get(columnKey) !== null && model.get(columnKey) !== undefined) {
// check length
// ==================== 长度限制验证 ====================
// 检查字段最大长度限制
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'maxlength')) {
if (!validator.isLength(strVal, 0, schema[tableName][columnKey].maxlength)) {
message = tpl(messages.valueExceedsMaxLength,
@ -97,12 +112,16 @@ function validateSchema(tableName, model, options) {
}
}
// check validations objects
// ==================== 自定义验证规则 ====================
// 执行schema中定义的自定义验证规则
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'validations')) {
validationErrors = validationErrors.concat(validator.validate(strVal, columnKey, schema[tableName][columnKey].validations, tableName));
validationErrors = validationErrors.concat(
validator.validate(strVal, columnKey, schema[tableName][columnKey].validations, tableName)
);
}
// check type
// ==================== 数据类型验证 ====================
// 检查整数类型字段
if (Object.prototype.hasOwnProperty.call(schema[tableName][columnKey], 'type')) {
if (schema[tableName][columnKey].type === 'integer' && !validator.isInt(strVal)) {
message = tpl(messages.valueIsNotInteger, {
@ -118,10 +137,13 @@ function validateSchema(tableName, model, options) {
}
});
// ==================== 验证结果处理 ====================
// 如果有验证错误使用Promise.reject返回错误数组
if (validationErrors.length !== 0) {
return Promise.reject(validationErrors);
}
// 验证通过返回成功的Promise
return Promise.resolve();
}
module.exports = validateSchema;
module.exports = validateSchema;

@ -1,47 +1,102 @@
const PostsService = require('./PostsService');
const PostsExporter = require('./PostsExporter');
const url = require('../../../server/api/endpoints/utils/serializers/output/utils/url');
/**
* Ghost CMS 文章服务工厂函数
*
* 这个模块负责创建和配置文章服务的单例实例采用工厂模式和依赖注入设计
* 将各种依赖组件组装成一个完整的文章服务它确保了服务实例的一致性和可测试性
*/
// 导入核心服务组件
const PostsService = require('./PostsService'); // 主文章业务逻辑服务
const PostsExporter = require('./PostsExporter'); // 文章导出功能组件
const url = require('../../../server/api/endpoints/utils/serializers/output/utils/url'); // URL序列化工具
/**
* @returns {InstanceType<PostsService>} instance of the PostsService
* 文章服务工厂函数
*
* 这个函数采用延迟加载模式在需要时才加载依赖模块避免循环依赖问题
* 它负责组装文章服务所需的所有依赖组件并返回配置完整的服务实例
*
* @returns {InstanceType<PostsService>} 配置完整的文章服务实例
*/
const getPostServiceInstance = () => {
const urlUtils = require('../../../shared/url-utils');
const labs = require('../../../shared/labs');
const models = require('../../models');
const PostStats = require('./stats/PostStats');
const emailService = require('../email-service');
const settingsCache = require('../../../shared/settings-cache');
const settingsHelpers = require('../settings-helpers');
// 延迟加载依赖模块(避免循环依赖)
const urlUtils = require('../../../shared/url-utils'); // URL处理工具
const labs = require('../../../shared/labs'); // 功能开关管理A/B测试、实验性功能
const models = require('../../models'); // 数据库模型层
const PostStats = require('./stats/PostStats'); // 文章统计服务
const emailService = require('../email-service'); // 邮件服务
const settingsCache = require('../../../shared/settings-cache'); // 设置缓存
const settingsHelpers = require('../settings-helpers'); // 设置辅助工具
// 实例化文章统计服务
const postStats = new PostStats();
/**
* 配置文章导出器实例
*
* 导出器负责将文章数据转换为CSV等格式支持数据分析需求
* 配置包括数据库模型映射URL生成函数和设置相关依赖
*/
const postsExporter = new PostsExporter({
// 数据库模型映射
models: {
Post: models.Post,
Newsletter: models.Newsletter,
Label: models.Label,
Product: models.Product
Post: models.Post, // 文章模型
Newsletter: models.Newsletter, // 新闻稿模型
Label: models.Label, // 标签模型
Product: models.Product // 产品模型
},
/**
* 文章URL生成函数
*
* 为导出功能提供文章的标准URL确保导出的数据包含正确的链接信息
*
* @param {Object} post - 文章对象
* @returns {string} 文章的完整URL
*/
getPostUrl(post) {
const jsonModel = post.toJSON();
const jsonModel = post.toJSON(); // 转换为JSON格式
// 使用URL序列化工具生成文章URL
url.forPost(post.id, jsonModel, {options: {}});
return jsonModel.url;
return jsonModel.url; // 返回生成的URL
},
settingsCache,
settingsHelpers
settingsCache, // 设置缓存依赖
settingsHelpers // 设置辅助工具依赖
});
/**
* 创建并返回配置完整的文章服务实例
*
* 采用依赖注入模式将所有必要的依赖通过构造函数注入
* 提高了代码的可测试性和可维护性
*/
return new PostsService({
urlUtils: urlUtils,
models: models,
urlUtils: urlUtils, // URL处理工具
models: models, // 数据库模型层
/**
* 功能开关检查函数
*
* 用于检查特定功能是否启用支持A/B测试和实验性功能
* 注意使用箭头函数而非bind以保持测试时的可替换性
*
* @param {string} flag - 功能标识符
* @returns {boolean} 功能是否启用
*/
isSet: flag => labs.isSet(flag), // don't use bind, that breaks test subbing of labs
stats: postStats,
emailService: emailService.service,
postsExporter
stats: postStats, // 文章统计服务
emailService: emailService.service, // 邮件服务取service属性
postsExporter // 文章导出器
});
};
// 导出工厂函数作为模块的主要接口
module.exports = getPostServiceInstance;
/**
* 暴露PostsService类仅用于测试目的
*
* 这个导出项允许测试代码直接访问PostsService类
* 便于进行单元测试和集成测试
* 在生产环境中应该通过工厂函数获取服务实例
*/
// exposed for testing purposes only
module.exports.PostsService = PostsService;
module.exports.PostsService = PostsService;
Loading…
Cancel
Save