From 2da99f65bbabedbf8d24b33c73d012a4bbf5c109 Mon Sep 17 00:00:00 2001 From: ws <3515701696@qq.com> Date: Mon, 20 Oct 2025 20:35:18 +0800 Subject: [PATCH 1/6] db_2 --- ghost/core/core/server/data/db/backup.js | 7 ++- ghost/core/core/server/data/db/connection.js | 22 +++---- .../server/data/exporter/export-filename.js | 58 +++++++++++++++---- .../core/core/server/data/schema/commands.js | 2 +- .../data/schema/default-settings/index.js | 1 + .../data/schema/fixtures/FixtureManager.js | 2 +- 6 files changed, 68 insertions(+), 24 deletions(-) diff --git a/ghost/core/core/server/data/db/backup.js b/ghost/core/core/server/data/db/backup.js index 55c99ab..758e1f0 100644 --- a/ghost/core/core/server/data/db/backup.js +++ b/ghost/core/core/server/data/db/backup.js @@ -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 | 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; } diff --git a/ghost/core/core/server/data/db/connection.js b/ghost/core/core/server/data/db/connection.js index 5933e89..ebd6a88 100644 --- a/ghost/core/core/server/data/db/connection.js +++ b/ghost/core/core/server/data/db/connection.js @@ -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; } diff --git a/ghost/core/core/server/data/exporter/export-filename.js b/ghost/core/core/server/data/exporter/export-filename.js index c2564d9..4e997e3 100644 --- a/ghost/core/core/server/data/exporter/export-filename.js +++ b/ghost/core/core/server/data/exporter/export-filename.js @@ -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} 生成的完整文件名(包含 .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'; } }; diff --git a/ghost/core/core/server/data/schema/commands.js b/ghost/core/core/server/data/schema/commands.js index d0ed9f4..e46d977 100644 --- a/ghost/core/core/server/data/schema/commands.js +++ b/ghost/core/core/server/data/schema/commands.js @@ -301,7 +301,7 @@ async function dropUnique(tableName, columns, transaction = db.knex) { }); } catch (err) { if (err.code === 'SQLITE_ERROR') { -logging.warn(`Constraint for '${columns}' does not exist for table '${tableName}'`); + logging.warn(`Constraint for '${columns}' does not exist for table '${tableName}'`); return; } if (err.code === 'ER_CANT_DROP_FIELD_OR_KEY') { diff --git a/ghost/core/core/server/data/schema/default-settings/index.js b/ghost/core/core/server/data/schema/default-settings/index.js index b83d391..bf6eb87 100644 --- a/ghost/core/core/server/data/schema/default-settings/index.js +++ b/ghost/core/core/server/data/schema/default-settings/index.js @@ -4,3 +4,4 @@ const defaultSettingsPath = config.get('paths').defaultSettings; const defaultSettings = require(defaultSettingsPath); module.exports = defaultSettings; +//一些默认配置文件 \ No newline at end of file diff --git a/ghost/core/core/server/data/schema/fixtures/FixtureManager.js b/ghost/core/core/server/data/schema/fixtures/FixtureManager.js index a763cdf..81fa194 100644 --- a/ghost/core/core/server/data/schema/fixtures/FixtureManager.js +++ b/ghost/core/core/server/data/schema/fixtures/FixtureManager.js @@ -1,4 +1,4 @@ -const _ = require('lodash'); +const _ = require('lodash'); //引入lodash库,用于处理数组、对象等数据结构 const logging = require('@tryghost/logging'); const {sequence} = require('@tryghost/promise'); -- 2.34.1 From d3fbccac3e6a291c46cf12f25e238cb3ac57a91c Mon Sep 17 00:00:00 2001 From: ws <3515701696@qq.com> Date: Mon, 20 Oct 2025 21:34:15 +0800 Subject: [PATCH 2/6] db_3 --- .../server/data/db/DatabaseStateManager.js | 14 +-- .../core/server/data/exporter/exporter.js | 12 +- .../core/server/data/exporter/table-lists.js | 1 + .../server/data/importer/handlers/image.js | 2 +- .../server/data/importer/import-manager.js | 42 ++++--- .../data/schema/fixtures/FixtureManager.js | 98 +++++++-------- ghost/core/core/server/data/schema/schema.js | 118 +++++++++--------- .../core/core/server/data/schema/validator.js | 66 ++++++---- 8 files changed, 188 insertions(+), 165 deletions(-) diff --git a/ghost/core/core/server/data/db/DatabaseStateManager.js b/ghost/core/core/server/data/db/DatabaseStateManager.js index 008d156..8f67951 100644 --- a/ghost/core/core/server/data/db/DatabaseStateManager.js +++ b/ghost/core/core/server/data/db/DatabaseStateManager.js @@ -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({ diff --git a/ghost/core/core/server/data/exporter/exporter.js b/ghost/core/core/server/data/exporter/exporter.js index 846ccc4..1dcb247 100644 --- a/ghost/core/core/server/data/exporter/exporter.js +++ b/ghost/core/core/server/data/exporter/exporter.js @@ -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 }, diff --git a/ghost/core/core/server/data/exporter/table-lists.js b/ghost/core/core/server/data/exporter/table-lists.js index 571721a..728fb85 100644 --- a/ghost/core/core/server/data/exporter/table-lists.js +++ b/ghost/core/core/server/data/exporter/table-lists.js @@ -1,4 +1,5 @@ // NOTE: these tables can be optionally included to have full db-like export +//比较重要的表,需要导出进行操作 const BACKUP_TABLES = [ 'actions', 'api_keys', diff --git a/ghost/core/core/server/data/importer/handlers/image.js b/ghost/core/core/server/data/importer/handlers/image.js index af9cf52..2234724 100644 --- a/ghost/core/core/server/data/importer/handlers/image.js +++ b/ghost/core/core/server/data/importer/handlers/image.js @@ -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, diff --git a/ghost/core/core/server/data/importer/import-manager.js b/ghost/core/core/server/data/importer/import-manager.js index af80de9..e96fb1f 100644 --- a/ghost/core/core/server/data/importer/import-manager.js +++ b/ghost/core/core/server/data/importer/import-manager.js @@ -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 ? '**/' : diff --git a/ghost/core/core/server/data/schema/fixtures/FixtureManager.js b/ghost/core/core/server/data/schema/fixtures/FixtureManager.js index 81fa194..ee404b8 100644 --- a/ghost/core/core/server/data/schema/fixtures/FixtureManager.js +++ b/ghost/core/core/server/data/schema/fixtures/FixtureManager.js @@ -5,9 +5,9 @@ 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} */ + /** + * 创建模型之间的关联关系 + * + * 处理多对多、一对多等复杂关系 + * 避免重复关联:检查是否已存在相同关系 + * + * @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; \ No newline at end of file diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index 79b0222..98f3124 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -116,45 +116,45 @@ module.exports = { ['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, @@ -173,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, @@ -197,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}, @@ -224,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', @@ -278,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}, @@ -307,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'}, @@ -316,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: { @@ -332,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}, @@ -347,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', @@ -363,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'}, @@ -389,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}, @@ -427,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}, @@ -450,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'] ] }, diff --git a/ghost/core/core/server/data/schema/validator.js b/ghost/core/core/server/data/schema/validator.js index e844114..fdc5a20 100644 --- a/ghost/core/core/server/data/schema/validator.js +++ b/ghost/core/core/server/data/schema/validator.js @@ -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; \ No newline at end of file -- 2.34.1 From 99192e1ee11240c36486644edbd2f89cdb99bcae Mon Sep 17 00:00:00 2001 From: ws <3515701696@qq.com> Date: Mon, 20 Oct 2025 23:36:07 +0800 Subject: [PATCH 3/6] db_4 --- ghost/core/core/server/api/endpoints/posts.js | 200 +++++++++++------- .../server/services/posts/posts-service.js | 105 ++++++--- 2 files changed, 205 insertions(+), 100 deletions(-) diff --git a/ghost/core/core/server/api/endpoints/posts.js b/ghost/core/core/server/api/endpoints/posts.js index 1732e9c..b8f3f84 100644 --- a/ghost/core/core/server/api/endpoints/posts.js +++ b/ghost/core/core/server/api/endpoints/posts.js @@ -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; \ No newline at end of file diff --git a/ghost/core/core/server/services/posts/posts-service.js b/ghost/core/core/server/services/posts/posts-service.js index b9d1642..32eff4f 100644 --- a/ghost/core/core/server/services/posts/posts-service.js +++ b/ghost/core/core/server/services/posts/posts-service.js @@ -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} instance of the PostsService + * 文章服务工厂函数 + * + * 这个函数采用延迟加载模式,在需要时才加载依赖模块,避免循环依赖问题。 + * 它负责组装文章服务所需的所有依赖组件,并返回配置完整的服务实例。 + * + * @returns {InstanceType} 配置完整的文章服务实例 */ 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; \ No newline at end of file -- 2.34.1 From 7f13ba4cc9013edb8abfbbe5a3c2682ae2a01262 Mon Sep 17 00:00:00 2001 From: wxz <1255422953@qq.com> Date: Tue, 21 Oct 2025 00:08:22 +0800 Subject: [PATCH 4/6] 7 --- .../analytics/AnalyticsWebTrafficPage.ts | 14 ++++++++ .../post-analytics/PostAnalyticsGrowthPage.ts | 11 ++++++ .../post-analytics/PostAnalyticsPage.ts | 35 +++++++++++++++++++ .../PostAnalyticsWebTrafficPage.ts | 23 ++++++++++++ .../analytics/post-analytics/growth.test.ts | 15 +++++++- .../analytics/post-analytics/overview.test.ts | 18 +++++++++- 6 files changed, 114 insertions(+), 2 deletions(-) diff --git a/e2e/helpers/pages/admin/analytics/AnalyticsWebTrafficPage.ts b/e2e/helpers/pages/admin/analytics/AnalyticsWebTrafficPage.ts index 27ecfd4..1e329dd 100644 --- a/e2e/helpers/pages/admin/analytics/AnalyticsWebTrafficPage.ts +++ b/e2e/helpers/pages/admin/analytics/AnalyticsWebTrafficPage.ts @@ -2,26 +2,35 @@ import {Locator, Page} from '@playwright/test'; import {AdminPage} from '../AdminPage'; export class AnalyticsWebTrafficPage extends AdminPage { + // 总浏览数 / 总访问量 选项卡的定位器 readonly totalViewsTab: Locator; + // 唯一访客数 选项卡的定位器 readonly totalUniqueVisitorsTab: Locator; + // 页面中展示流量折线/图表的容器定位器(使用 data-testid) private readonly webGraph: Locator; + // “Top content” 卡片及其内部的选项卡定位器(Posts & pages / Posts / Pages) readonly topContentCard: Locator; readonly postsAndPagesButton: Locator; readonly postsButton: Locator; readonly pagesButton: Locator; + // “Top sources” 卡片的定位器(显示来源统计) public readonly topSourcesCard: Locator; constructor(page: Page) { super(page); + // 页面对应的 hash 路由,用于 goto() 等导航判断 this.pageUrl = '/ghost/#/analytics/web'; + // 使用可访问性角色定位选项卡(便于稳定定位) this.totalViewsTab = page.getByRole('tab', {name: 'Total views'}); this.totalUniqueVisitorsTab = page.getByRole('tab', {name: 'Unique visitors'}); + // 使用 data-testid 定位图表容器,便于直接读取文本或存在性检查 this.webGraph = page.getByTestId('web-graph'); + // Top content 卡片及内部按钮定位 this.topContentCard = page.getByTestId('top-content-card'); this.postsAndPagesButton = this.topContentCard.getByRole('tab', {name: 'Posts & pages'}); this.postsButton = this.topContentCard.getByRole('tab', {name: 'Posts', exact: true}); @@ -30,22 +39,27 @@ export class AnalyticsWebTrafficPage extends AdminPage { this.topSourcesCard = page.getByTestId('top-sources-card'); } + // 返回 webGraph 的文本内容(可用于断言图表上方的汇总数或提示文本) async totalViewsContent() { return await this.webGraph.textContent(); } + // 返回“Unique visitors”选项卡的文本内容(通常包含数字或标签) async totalUniqueVisitorsContent() { return await this.totalUniqueVisitorsTab.textContent(); } + // 切换到“Total views”选项卡(模拟用户点击) async viewTotalViews() { await this.totalViewsTab.click(); } + // 切换到“Unique visitors”选项卡(模拟用户点击) async viewTotalUniqueVisitors() { await this.totalUniqueVisitorsTab.click(); } + // 读取 webGraph 的文本内容(方法名表示读取图表内容) async viewWebGraphContent() { await this.webGraph.textContent(); } diff --git a/e2e/helpers/pages/admin/analytics/post-analytics/PostAnalyticsGrowthPage.ts b/e2e/helpers/pages/admin/analytics/post-analytics/PostAnalyticsGrowthPage.ts index 0ec67dc..c98ea4e 100644 --- a/e2e/helpers/pages/admin/analytics/post-analytics/PostAnalyticsGrowthPage.ts +++ b/e2e/helpers/pages/admin/analytics/post-analytics/PostAnalyticsGrowthPage.ts @@ -1,17 +1,28 @@ import {Locator, Page} from '@playwright/test'; import {AdminPage} from '../../AdminPage'; +/** + * PostAnalyticsGrowthPage + * 封装文章分析页面中 "Growth(增长)" 视图的定位器与基础交互。 + * 该页面对象用于 e2e 测试中获取增长相关卡片(成员、来源)并执行简单操作。 + */ export class PostAnalyticsGrowthPage extends AdminPage { + // 成员统计卡片的容器定位器(使用 data-testid,便于稳定定位) readonly membersCard: Locator; + // 成员卡片内的“View member”按钮,用于导航到 Members 页面查看详情 readonly viewMemberButton: Locator; + // Top sources(流量来源)卡片的容器定位器 readonly topSourcesCard: Locator; constructor(page: Page) { super(page); + // 通过 data-testid 定位 members 卡片(包含免费/付费成员数等摘要) this.membersCard = this.page.getByTestId('members-card'); + // 在 members 卡片内定位名为 'View member' 的按钮(用于点击查看成员列表) this.viewMemberButton = this.membersCard.getByRole('button', {name: 'View member'}); + // 定位 top sources 卡片(显示访问来源,如直接、搜索、社交等) this.topSourcesCard = this.page.getByTestId('top-sources-card'); } } diff --git a/e2e/helpers/pages/admin/analytics/post-analytics/PostAnalyticsPage.ts b/e2e/helpers/pages/admin/analytics/post-analytics/PostAnalyticsPage.ts index 57ee311..017dfd7 100644 --- a/e2e/helpers/pages/admin/analytics/post-analytics/PostAnalyticsPage.ts +++ b/e2e/helpers/pages/admin/analytics/post-analytics/PostAnalyticsPage.ts @@ -1,52 +1,87 @@ import {Locator, Page} from '@playwright/test'; import {AdminPage} from '../../AdminPage'; +/** + * GrowthSection + * 封装文章分析页面中 “Growth(增长)” 区块的定位器与常用交互。 + * 该区块通常包含成员相关摘要与“View more”按钮用于展开详情。 + */ class GrowthSection extends AdminPage { + // Growth 卡片容器定位器(使用 data-testid) readonly card: Locator; + // Growth 卡片内的“View more”按钮定位器 readonly viewMoreButton: Locator; constructor(page: Page) { super(page); + // 通过 data-testid 定位 growth 卡片,便于稳定选择 this.card = this.page.getByTestId('growth'); + // 在卡片内查找名为 'View more' 的按钮(用于查看更详细的增长数据) this.viewMoreButton = this.card.getByRole('button', {name: 'View more'}); } } +/** + * WebPerformanceSection + * 封装文章分析页面中 “Web performance(网站表现)” 区块的定位器与常用交互。 + * 包含唯一访客数的定位器与“View more”按钮。 + */ class WebPerformanceSection extends AdminPage { + // Web performance 卡片容器定位器 readonly card: Locator; + // 卡片内显示“unique visitors(唯一访客)”的元素定位器 readonly uniqueVisitors: Locator; + // 卡片内的“View more”按钮定位器 readonly viewMoreButton: Locator; constructor(page: Page) { super(page); + // 使用 data-testid 定位 web-performance 卡片 this.card = this.page.getByTestId('web-performance'); + // 在卡片内定位显示唯一访客数的元素 this.uniqueVisitors = this.card.getByTestId('unique-visitors'); + // 在卡片内定位“View more”按钮(用于查看流量详情) this.viewMoreButton = this.card.getByRole('button', {name: 'View more'}); } } +/** + * PostAnalyticsPage + * 文章分析页面的页面对象,聚合了不同分析视图(Overview / Web traffic / Growth) + * 以及对应区块(growthSection, webPerformanceSection),并提供页面加载等待方法。 + */ export class PostAnalyticsPage extends AdminPage { + // 页面上方的导航/选项按钮:Overview、Web traffic、Growth readonly overviewButton: Locator; readonly webTrafficButton: Locator; readonly growthButton: Locator; + // 区块对象,分别封装 Growth 和 Web Performance 子区域的定位器/操作 readonly growthSection: GrowthSection; readonly webPerformanceSection: WebPerformanceSection; constructor(page: Page) { super(page); + // 设置此页面对应的路由(用于 goto() 或页面断言) this.pageUrl = '/ghost/#/analytics'; + // 使用可访问性角色定位顶部视图切换按钮,便于稳定点击 this.overviewButton = this.page.getByRole('button', {name: 'Overview'}); this.webTrafficButton = this.page.getByRole('button', {name: 'Web traffic'}); this.growthButton = this.page.getByRole('button', {name: 'Growth'}); + // 初始化子区块页面对象,传入同一 page 实例以共享上下文 this.growthSection = new GrowthSection(page); this.webPerformanceSection = new WebPerformanceSection(page); } + /** + * waitForPageLoad + * 等待文章分析页面加载完成的简单策略:等待 webPerformanceSection 卡片可见。 + * 该方法用于在测试中确保页面元素可交互,避免因为异步渲染导致的点击失败。 + */ async waitForPageLoad() { await this.webPerformanceSection.card.waitFor({state: 'visible'}); } diff --git a/e2e/helpers/pages/admin/analytics/post-analytics/PostAnalyticsWebTrafficPage.ts b/e2e/helpers/pages/admin/analytics/post-analytics/PostAnalyticsWebTrafficPage.ts index bb596ca..a3c4871 100644 --- a/e2e/helpers/pages/admin/analytics/post-analytics/PostAnalyticsWebTrafficPage.ts +++ b/e2e/helpers/pages/admin/analytics/post-analytics/PostAnalyticsWebTrafficPage.ts @@ -1,8 +1,31 @@ import {Page} from '@playwright/test'; import {AdminPage} from '../../AdminPage'; +/** + * PostAnalyticsWebTrafficPage + * -------------------------- + * Playwright 页面对象:用于封装文章级别的 “Web Traffic”(网站流量)分析视图的定位器与操作。 + * + * 说明: + * - 该类当前只是继承自 AdminPage 的空壳,作为占位以便后续在文章分析页面中添加特定定位器与交互方法。 + * - 推荐在此处添加常用的定位器(例如图表容器、Top sources、Top content、时间范围切换等) + * 以及便于测试的读取/切换方法(例如 getTotalViews(), clickTopSourcesTab() 等)。 + * + * 示例 TODO(可按需实现): + * - this.webGraph = page.getByTestId('post-web-graph'); + * - async getTotalViews() { return await this.webGraphLocator.textContent(); } + * - async selectTimeRange(range: '7d'|'30d'|'90d') { ... } + * + * 目的:把文章级别的流量相关交互都集中在此类,便于 e2e 测试重用与维护。 + */ export class PostAnalyticsWebTrafficPage extends AdminPage { constructor(page: Page) { super(page); + // TODO: 在这里初始化文章级 Web 流量页面的定位器,例如: + // this.pageUrl = '/ghost/#/editor/analytics/post/...'; // 如有需要可设置具体路由 + // + // 示例(占位): + // this.webGraph = page.getByTestId('post-web-graph'); + // this.topSourcesCard = page.getByTestId('post-top-sources-card'); } } diff --git a/e2e/tests/admin/analytics/post-analytics/growth.test.ts b/e2e/tests/admin/analytics/post-analytics/growth.test.ts index 7cdc3af..8668095 100644 --- a/e2e/tests/admin/analytics/post-analytics/growth.test.ts +++ b/e2e/tests/admin/analytics/post-analytics/growth.test.ts @@ -6,36 +6,49 @@ import { MembersPage } from '../../../../helpers/pages/admin'; +// 测试套件:Ghost 管理后台 - 文章分析(Post Analytics)- Growth(增长)页 test.describe('Ghost Admin - Post Analytics - Growth', () => { + // 每个测试开始前的准备工作:导航并打开目标文章的分析页面,再点击 Growth 选项 test.beforeEach(async ({page}) => { const analyticsOverviewPage = new AnalyticsOverviewPage(page); await analyticsOverviewPage.goto(); + // 在概览页点击最新文章的 analytics 按钮,进入文章分析面板 await analyticsOverviewPage.latestPost.analyticsButton.click(); - // TODO: check post analytics component, we shouldn't need to wait on page load to be able to click growth link + // TODO 注释保留:理想情况下不应需等待页面完全加载即可点击 growth 链接 const postAnalyticsPage = new PostAnalyticsPage(page); + // 等待文章分析页面加载完成(确保元素可交互) await postAnalyticsPage.waitForPageLoad(); + // 点击 Growth 按钮,进入增长视图 await postAnalyticsPage.growthButton.click(); }); + // 测试:空成员卡片应显示 Free members 字样并且数量为 0 test('empty members card', async ({page}) => { const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page); + // 断言 members 卡片包含“Free members”标签 await expect(postAnalyticsPageGrowthPage.membersCard).toContainText('Free members'); + // 断言成员数量显示为 0(空数据场景) await expect(postAnalyticsPageGrowthPage.membersCard).toContainText('0'); }); + // 测试:在空成员场景点击“查看成员”应跳转到 Members 页面并显示无匹配结果 test('empty members card - view member', async ({page}) => { const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page); + // 点击“查看成员”按钮(应导航到 Members 列表) await postAnalyticsPageGrowthPage.viewMemberButton.click(); const membersPage = new MembersPage(page); + // 断言 Members 页面显示“无成员匹配”的提示文本 await expect(membersPage.body).toContainText('No members match'); }); + // 测试:Top sources 卡片在无数据时显示“无来源数据”提示 test('empty top sources card', async ({page}) => { const postAnalyticsPageGrowthPage = new PostAnalyticsGrowthPage(page); + // 断言 top sources 卡片包含“No sources data available” await expect(postAnalyticsPageGrowthPage.topSourcesCard).toContainText('No sources data available'); }); }); diff --git a/e2e/tests/admin/analytics/post-analytics/overview.test.ts b/e2e/tests/admin/analytics/post-analytics/overview.test.ts index bab8dcd..b45cf07 100644 --- a/e2e/tests/admin/analytics/post-analytics/overview.test.ts +++ b/e2e/tests/admin/analytics/post-analytics/overview.test.ts @@ -6,30 +6,43 @@ import { PostAnalyticsWebTrafficPage } from '../../../../helpers/pages/admin'; +/** + * 文件说明: + * 这个测试文件包含文章级别 Analytics 的 Overview 视图相关的 e2e 测试。 + * 场景主要验证在“空数据”情况下,各个卡片/选项卡的可见性和提示文本。 + */ + test.describe('Ghost Admin - Post Analytics - Overview', () => { + // 在每个测试前都执行:导航到 Analytics 概览并打开最新文章的 analytics 面板 test.beforeEach(async ({page}) => { const analyticsOverviewPage = new AnalyticsOverviewPage(page); await analyticsOverviewPage.goto(); - + // 在概览页点击“最新文章”的 analytics 按钮,进入文章分析面板 await analyticsOverviewPage.latestPost.analyticsButton.click(); }); + // 验证概览页面存在三个主要选项卡:Overview / Web traffic / Growth test('empty page with all tabs', async ({page}) => { const postAnalyticsPage = new PostAnalyticsPage(page); + // 三个切换按钮都应可见,确保页面结构完整 await expect(postAnalyticsPage.overviewButton).toBeVisible(); await expect(postAnalyticsPage.webTrafficButton).toBeVisible(); await expect(postAnalyticsPage.growthButton).toBeVisible(); }); + // 在 Overview -> Web performance 区块点击 "View more" 应进入 Web traffic 视图并显示无访问提示 test('empty page - overview - web performance - view more', async ({page}) => { const postAnalyticsPage = new PostAnalyticsPage(page); + // 点击 Web performance 区块的 “View more” 按钮,进入流量详情页 await postAnalyticsPage.webPerformanceSection.viewMoreButton.click(); const postAnalyticsWebTrafficPage = new PostAnalyticsWebTrafficPage(page); + // 在空数据场景下,web traffic 页面应包含“No visitors in the last 30 days”提示 await expect(postAnalyticsWebTrafficPage.body).toContainText('No visitors in the last 30 days'); }); + // 验证 Growth 区块在空数据情况下显示“Free members” 并且数量为 0 test('empty page - overview - growth', async ({page}) => { const postAnalyticsPage = new PostAnalyticsPage(page); @@ -37,11 +50,14 @@ test.describe('Ghost Admin - Post Analytics - Overview', () => { await expect(postAnalyticsPage.growthSection.card).toContainText('0'); }); + // 在 Overview -> Growth 区块点击 "View more" 应进入 Growth 详情并显示无来源数据提示 test('empty page - overview - growth - view more', async ({page}) => { const postAnalyticsPage = new PostAnalyticsPage(page); + // 点击 Growth 卡片的“View more”按钮,进入增长详情页 await postAnalyticsPage.growthSection.viewMoreButton.click(); const postAnalyticsGrowthPage = new PostAnalyticsGrowthPage(page); + // 在空数据场景下,top sources 卡片应展示“No sources data available”提示 await expect(postAnalyticsGrowthPage.topSourcesCard).toContainText('No sources data available'); }); }); -- 2.34.1 From 444311e70a4a8ecee67731f4eebb63effc8a33e5 Mon Sep 17 00:00:00 2001 From: ZYY <2293590393@qq.com> Date: Tue, 21 Oct 2025 00:09:29 +0800 Subject: [PATCH 5/6] 1 --- .../src/components/global/ImageLightbox.tsx | 129 ++++++++++++++---- 1 file changed, 99 insertions(+), 30 deletions(-) diff --git a/apps/admin-x-activitypub/src/components/global/ImageLightbox.tsx b/apps/admin-x-activitypub/src/components/global/ImageLightbox.tsx index 2691530..1e04325 100644 --- a/apps/admin-x-activitypub/src/components/global/ImageLightbox.tsx +++ b/apps/admin-x-activitypub/src/components/global/ImageLightbox.tsx @@ -1,52 +1,72 @@ -import React, {useCallback, useEffect, useState} from 'react'; -import {Button, Dialog, DialogClose, DialogContent, LucideIcon} from '@tryghost/shade'; -import {ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; -import {getAttachment} from '@components/feed/FeedItem'; +import React, { useCallback, useEffect, useState } from 'react'; +// 导入UI组件:按钮、对话框及内容容器、Lucide图标库 +import { Button, Dialog, DialogClose, DialogContent, LucideIcon } from '@tryghost/shade'; +// 导入活动发布对象的类型定义 +import { ObjectProperties } from '@tryghost/admin-x-framework/api/activitypub'; +// 导入获取附件的工具函数 +import { getAttachment } from '@components/feed/FeedItem'; +// 定义灯箱中图片的类型接口 export interface LightboxImage { - url: string; - alt: string; + url: string; // 图片URL + alt: string; // 图片替代文本 } +// 定义灯箱状态的类型接口 export interface LightboxState { - images: LightboxImage[]; - currentIndex: number; - isOpen: boolean; + images: LightboxImage[]; // 所有图片列表 + currentIndex: number; // 当前显示图片的索引 + isOpen: boolean; // 灯箱是否打开 } +/** + * 自定义Hook:管理图片灯箱的状态和操作 + * @param object - 包含图片的活动发布对象 + * @returns 灯箱状态及相关操作方法 + */ export function useLightboxImages(object: ObjectProperties | null) { + // 初始化灯箱状态 const [lightboxState, setLightboxState] = useState({ images: [], currentIndex: 0, isOpen: false }); + /** + * 从对象中提取所有图片 + * @param obj - 活动发布对象 + * @returns 提取出的图片数组 + */ const getAllImagesFromAttachment = (obj: ObjectProperties): LightboxImage[] => { + // 获取对象的附件 const attachment = getAttachment(obj); if (!attachment) { return []; } + // 处理附件为数组的情况 if (Array.isArray(attachment)) { return attachment.map((item, index) => ({ url: item.url, - alt: item.name || `Image-${index}` + alt: item.name || `Image-${index}` // 用索引作为默认alt文本 })); } + // 处理单个图片附件 if (attachment.mediaType?.startsWith('image/') || attachment.type === 'Image') { return [{ url: attachment.url, - alt: attachment.name || 'Image' + alt: attachment.name || 'Image' // 用默认文本作为fallback }]; } + // 处理对象中直接包含image字段的情况 if (obj.image) { let imageUrl; if (typeof obj.image === 'string') { - imageUrl = obj.image; + imageUrl = obj.image; // 图片URL直接是字符串 } else { - imageUrl = obj.image?.url; + imageUrl = obj.image?.url; // 图片是对象,取其url属性 } if (imageUrl) { @@ -57,17 +77,23 @@ export function useLightboxImages(object: ObjectProperties | null) { } } - return []; + return []; // 没有找到图片时返回空数组 }; + /** + * 打开灯箱并显示指定图片 + * @param clickedUrl - 被点击图片的URL + */ const openLightbox = (clickedUrl: string) => { if (!object) { - return; + return; // 对象为空时不执行操作 } + // 获取所有图片并找到被点击图片的索引 const images = getAllImagesFromAttachment(object); const clickedIndex = images.findIndex(img => img.url === clickedUrl); + // 找到对应图片时更新灯箱状态 if (clickedIndex !== -1) { setLightboxState({ images, @@ -77,6 +103,9 @@ export function useLightboxImages(object: ObjectProperties | null) { } }; + /** + * 关闭灯箱 + */ const closeLightbox = () => { setLightboxState(prev => ({ ...prev, @@ -84,6 +113,10 @@ export function useLightboxImages(object: ObjectProperties | null) { })); }; + /** + * 导航到指定索引的图片 + * @param newIndex - 目标图片索引 + */ const navigateToIndex = (newIndex: number) => { setLightboxState(prev => ({ ...prev, @@ -99,14 +132,18 @@ export function useLightboxImages(object: ObjectProperties | null) { }; } +// 图片灯箱组件的属性接口 interface ImageLightboxProps { - images: LightboxImage[]; - currentIndex: number; - isOpen: boolean; - onClose: () => void; - onNavigate: (newIndex: number) => void; + images: LightboxImage[]; // 图片列表 + currentIndex: number; // 当前显示图片索引 + isOpen: boolean; // 是否打开 + onClose: () => void; // 关闭回调 + onNavigate: (newIndex: number) => void; // 导航回调 } +/** + * 图片灯箱组件:用于放大查看图片,支持左右导航和键盘控制 + */ const ImageLightbox: React.FC = ({ images, currentIndex, @@ -114,44 +151,65 @@ const ImageLightbox: React.FC = ({ onClose, onNavigate }) => { + // 判断是否是第一张/最后一张图片 const isFirstImage = currentIndex === 0; const isLastImage = currentIndex === images.length - 1; + /** + * 导航到下一张图片 + * 使用useCallback缓存函数,避免不必要的重渲染 + */ const goToNext = useCallback(() => { + // 只有一张图片或已经是最后一张时不执行 if (images.length <= 1 || isLastImage) { return; } + // 计算下一张索引(循环导航) const nextIndex = (currentIndex + 1) % images.length; onNavigate(nextIndex); }, [images.length, isLastImage, currentIndex, onNavigate]); + /** + * 导航到上一张图片 + * 使用useCallback缓存函数 + */ const goToPrev = useCallback(() => { + // 只有一张图片或已经是第一张时不执行 if (images.length <= 1 || isFirstImage) { return; } + // 计算上一张索引(循环导航) const prevIndex = (currentIndex - 1 + images.length) % images.length; onNavigate(prevIndex); }, [images.length, isFirstImage, currentIndex, onNavigate]); + /** + * 监听键盘事件,支持左右箭头导航 + */ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!isOpen) { - return; + return; // 灯箱关闭时不处理 } + // 右箭头导航到下一张(非最后一张时) if (e.key === 'ArrowRight' && !isLastImage) { goToNext(); - } else if (e.key === 'ArrowLeft' && !isFirstImage) { + } + // 左箭头导航到上一张(非第一张时) + else if (e.key === 'ArrowLeft' && !isFirstImage) { goToPrev(); } }; window.addEventListener('keydown', handleKeyDown); + // 组件卸载时移除事件监听 return () => { window.removeEventListener('keydown', handleKeyDown); }; }, [isOpen, currentIndex, images.length, goToNext, goToPrev, isLastImage, isFirstImage]); + // 灯箱未打开或没有图片时不渲染 if (!isOpen || images.length === 0) { return null; } @@ -159,37 +217,46 @@ const ImageLightbox: React.FC = ({ return ( { if (!open) { onClose(); } }} > - onClose()}> + {/* 灯箱内容容器,全屏显示且居中 */} + onClose()} // 点击空白区域关闭 + > + {/* 当前显示的图片 */} {images[currentIndex].alt} e.stopPropagation()} + onClick={e => e.stopPropagation()} // 点击图片本身不关闭灯箱 /> + {/* 多张图片时显示导航按钮 */} {images.length > 1 && ( <> + {/* 上一张按钮 */} + {/* 下一张按钮 */} )} + + {/* 关闭按钮 */}