0.2 #2

Merged
m3i46ogeb merged 11 commits from main into develop 3 months ago

@ -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<LightboxState>({
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<ImageLightboxProps> = ({
images,
currentIndex,
@ -114,44 +151,65 @@ const ImageLightbox: React.FC<ImageLightboxProps> = ({
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<ImageLightboxProps> = ({
return (
<Dialog
open={isOpen}
// 监听对话框状态变化,关闭时触发回调
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<DialogContent className="top-[50%] h-[100vh] max-h-[100vh] w-[100vw] max-w-[100vw] translate-y-[-50%] items-center border-none bg-transparent p-0 shadow-none data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-100 data-[state=closed]:slide-out-to-top-[50%] data-[state=open]:slide-in-from-top-[50%]" onClick={() => onClose()}>
{/* 灯箱内容容器,全屏显示且居中 */}
<DialogContent
className="top-[50%] h-[100vh] max-h-[100vh] w-[100vw] max-w-[100vw] translate-y-[-50%] items-center border-none bg-transparent p-0 shadow-none data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-100 data-[state=closed]:slide-out-to-top-[50%] data-[state=open]:slide-in-from-top-[50%]"
onClick={() => onClose()} // 点击空白区域关闭
>
{/* 当前显示的图片 */}
<img
alt={images[currentIndex].alt}
className="mx-auto max-h-[90vh] max-w-[90vw] object-contain"
className="mx-auto max-h-[90vh] max-w-[90vw] object-contain" // 保持比例,限制最大尺寸
src={images[currentIndex].url}
onClick={e => e.stopPropagation()}
onClick={e => e.stopPropagation()} // 点击图片本身不关闭灯箱
/>
{/* 多张图片时显示导航按钮 */}
{images.length > 1 && (
<>
{/* 上一张按钮 */}
<Button
className="absolute left-5 top-1/2 size-11 -translate-y-1/2 rounded-full bg-black/50 p-0 pr-0.5 hover:bg-black/70"
disabled={isFirstImage}
disabled={isFirstImage} // 第一张时禁用
onClick={(e) => {
e.stopPropagation();
e.stopPropagation(); // 阻止事件冒泡(避免关闭灯箱)
goToPrev();
}}
>
<LucideIcon.ChevronLeft className="!size-6" />
<span className="sr-only">Previous image</span>
<span className="sr-only">Previous image</span> // 屏幕阅读器文本
</Button>
{/* 下一张按钮 */}
<Button
className="absolute right-5 top-1/2 size-11 -translate-y-1/2 rounded-full bg-black/50 p-0 pl-0.5 hover:bg-black/70"
disabled={isLastImage}
disabled={isLastImage} // 最后一张时禁用
onClick={(e) => {
e.stopPropagation();
goToNext();
@ -200,6 +267,8 @@ const ImageLightbox: React.FC<ImageLightboxProps> = ({
</Button>
</>
)}
{/* 关闭按钮 */}
<DialogClose asChild>
<Button className="absolute right-5 top-5 size-11 rounded-full bg-black/50 p-0 hover:bg-black/70">
<LucideIcon.X className="!size-5" />
@ -211,4 +280,4 @@ const ImageLightbox: React.FC<ImageLightboxProps> = ({
);
};
export default ImageLightbox;
export default ImageLightbox;

@ -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();
}

@ -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');
}
}

@ -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'});
}

@ -1,8 +1,31 @@
import {Page} from '@playwright/test';
import {AdminPage} from '../../AdminPage';
/**
* PostAnalyticsWebTrafficPage
* --------------------------
* Playwright Web Traffic
*
*
* - AdminPage 便
* - Top sourcesTop 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');
}
}

@ -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');
});
});

@ -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');
});
});

@ -1,13 +1,20 @@
import AdminXComponent from './admin-x-component';
import {inject as service} from '@ember/service';
// AdminXActivityPub 继承自 AdminXComponent提供与 ActivityPub社交网络功能相关的绑定和属性。
export default class AdminXActivityPub extends AdminXComponent {
// 注入 upgradeStatus 服务,用于查询或响应系统升级状态(保留以备使用或观察)。
@service upgradeStatus;
// 注入 settings 服务,包含站点设置(例如 socialWebEnabled 标志)。
@service settings;
// 指定该组件所属的 NPM 包名,供框架或调试时识别来源。
static packageName = '@tryghost/admin-x-activitypub';
// additionalProps 返回一个对象,这些属性会传递给子组件或渲染模板。
// 这里我们暴露了 activityPubEnabled基于 settings 服务中的 socialWebEnabled 标志。
additionalProps = () => ({
// activityPubEnabled: 布尔值指示是否启用了社交网络ActivityPub功能。
activityPubEnabled: this.settings.socialWebEnabled
});
}

@ -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 ? '**/' :

@ -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') {

@ -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;

@ -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']
]
},

@ -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