diff --git a/packages/admin/config/routes.js b/packages/admin/config/routes.js index 062abb721..5abf9a1e3 100644 --- a/packages/admin/config/routes.js +++ b/packages/admin/config/routes.js @@ -50,6 +50,7 @@ export default [ routes: [ { name: '数据管理', path: '/site/data', component: './DataManage' }, { name: '评论管理', path: '/site/comment', component: './CommentManage' }, + { name: '流水线', path: '/site/pipeline', component: './Pipeline'}, { name: '系统设置', path: '/site/setting', component: './SystemConfig' }, { name: '自定义页面', path: '/site/customPage', component: './CustomPage' }, { name: '日志管理', path: '/site/log', component: './LogManage' }, diff --git a/packages/admin/src/pages/Code/index.tsx b/packages/admin/src/pages/Code/index.tsx index a7095d2a5..ba1301904 100644 --- a/packages/admin/src/pages/Code/index.tsx +++ b/packages/admin/src/pages/Code/index.tsx @@ -6,12 +6,17 @@ import { getCustomPageFolderTreeByPath, updateCustomPage, updateCustomPageFileInFolder, + getPipelineById, + updatePipelineById, + getPipelineConfig } from '@/services/van-blog/api'; import { DownOutlined } from '@ant-design/icons'; import { PageContainer } from '@ant-design/pro-layout'; import { Button, Dropdown, Menu, message, Modal, Space, Spin, Tag, Tree } from 'antd'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { history } from 'umi'; +import PipelineModal from '../Pipeline/components/PipelineModal'; +import RunCodeModal from '../Pipeline/components/RunCodeModal'; import './index.less'; const { DirectoryTree } = Tree; @@ -20,6 +25,7 @@ export default function () { const [currObj, setCurrObj] = useState({}); const [node, setNode] = useState(); const [selectedKeys, setSelectedKeys] = useState([]); + const [pipelineConfig, setPipelineConfig] = useState([]); const [pathPrefix, setPathPrefix] = useState(''); const [treeData, setTreeData] = useState([{ title: 'door', key: '123' }]); const [uploadLoading, setUploadLoading] = useState(false); @@ -29,13 +35,23 @@ export default function () { const [editorHeight, setEditorHeight] = useState('calc(100vh - 82px)'); const type = history.location.query?.type; const path = history.location.query?.path; + const id = history.location.query?.id; const isFolder = type == 'folder'; const typeMap = { file: '单文件页面', folder: '多文件页面', + pipeline: '流水线', }; + useEffect(() => { + getPipelineConfig().then(({ data }) => { + setPipelineConfig(data); + }); + }, []) const language = useMemo(() => { + if (type == 'pipeline') { + return 'javascript'; + } if (!node) { return 'html'; } @@ -100,6 +116,30 @@ export default function () { setEditorHeight(`calc(100vh - ${HeaderHeight + 12}px)`); }; + const onKeyDown = (ev) => { + let save = false; + if (ev.metaKey == true && ev.key.toLocaleLowerCase() == 's') { + save = true; + } + if (ev.ctrlKey == true && ev.key.toLocaleLowerCase() == 's') { + save = true; + } + if (save) { + event?.preventDefault(); + ev?.preventDefault(); + handleSave(); + } + return false; + }; + + useEffect(() => { + window.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('keydown', onKeyDown); + }; + }, [currObj, value, type]); + + useEffect(() => { setTimeout(() => { updateEditorSize(); @@ -114,7 +154,7 @@ export default function () { setEditorLoading(false); }; const fetchData = useCallback(async () => { - if (!path) { + if (!path && !id) { message.error('无有效信息,无法获取数据!'); return; } else { @@ -124,7 +164,20 @@ export default function () { const { data } = await getCustomPageFolderTreeByPath(path); if (data) setTreeData(data); setTreeLoading(false); - } else { + } else if (type == "pipeline") { + if (!id) { + message.error('无有效信息,无法获取数据!'); + return; + } + setEditorLoading(true); + const { data } = await getPipelineById(id); + if (data) { + setCurrObj(data); + setValue(data?.script || ''); + } + setEditorLoading(false); + } + else { setEditorLoading(true); const { data } = await getCustomPageByPath(path); if (data) { @@ -147,7 +200,14 @@ export default function () { await updateCustomPage({ ...currObj, html: value }); setEditorLoading(false); message.success('当前编辑器内文件保存成功!'); - } else { + } else if (type == "pipeline") { + setEditorLoading(true); + await updatePipelineById(currObj.id, { script: value }); + setEditorLoading(false); + message.success('当前编辑器内脚本保存成功!'); + } + + else { setEditorLoading(true); await updateCustomPageFileInFolder(path, node?.key, value); setEditorLoading(false); @@ -164,6 +224,16 @@ export default function () { label: '保存', onClick: handleSave, }, + ...(type == "pipeline" ? [ + { + key: "runPipeline", + label: 调试脚本} /> + }, + { + key: 'editPipelineInfo', + label: 编辑信息} onFinish={(vals) => { console.log(vals) }} initialValues={currObj} /> + } + ] : []), ...(isFolder ? [ { @@ -207,14 +277,13 @@ export default function () { }, ] : []), - - { + ...(type == 'file' ? [{ key: 'view', label: '查看', onClick: () => { window.open(`/c${path}`); }, - }, + }] : []) ]} > ); @@ -230,6 +299,10 @@ export default function () { {currObj?.name} <> {typeMap[type] || '未知类型'} + {type == "pipeline" && <> + {pipelineConfig?.find(p => p.eventName == currObj.eventName)?.eventNameChinese} + {pipelineConfig?.find(p => p.eventName == currObj.eventName)?.passive ? 非阻塞 : 阻塞} + } ), diff --git a/packages/admin/src/pages/LogManage/index.jsx b/packages/admin/src/pages/LogManage/index.jsx index 22973e56f..2769521d9 100644 --- a/packages/admin/src/pages/LogManage/index.jsx +++ b/packages/admin/src/pages/LogManage/index.jsx @@ -2,11 +2,13 @@ import { useTab } from '@/services/van-blog/useTab'; import { PageContainer } from '@ant-design/pro-layout'; import thinstyle from '../Welcome/index.less'; import Login from './tabs/Login'; +import Pipeline from "./tabs/Pipeline"; export default function () { const tabMap = { login: , + pipeline: }; - const [tab, setTab] = useTab('login', 'tab'); + const [tab, setTab] = useTab('pipeline', 'tab'); return ( diff --git a/packages/admin/src/pages/LogManage/tabs/Pipeline.tsx b/packages/admin/src/pages/LogManage/tabs/Pipeline.tsx new file mode 100644 index 000000000..9e4f1cb5a --- /dev/null +++ b/packages/admin/src/pages/LogManage/tabs/Pipeline.tsx @@ -0,0 +1,113 @@ +import { getLog, getPipelineConfig } from '@/services/van-blog/api'; +import { ProTable } from '@ant-design/pro-components'; +import { Modal, Tag } from 'antd'; +import { useEffect, useRef, useState } from 'react'; +import { history } from "umi" + +export default function () { + const actionRef = useRef(); + const [pipelineConfig, setPipelineConfig] = useState([]); + useEffect(() => { + getPipelineConfig().then(({ data }) => { + setPipelineConfig(data); + }) + }, []); + const columns = [ + { + title: '序号', + align: 'center', + width: 50, + render: (text, record, index) => { + return index + 1; + }, + }, + { + title: '流水线 id', + dataIndex: 'pipelineId', + key: 'pipelineId', + align: 'center', + }, + { + title: '名称', + dataIndex: 'pipelineName', + key: 'pipelineName', + align: 'center', + render: (name,record) => { history.push('/code?type=pipeline&id=' +record.pipelineId) }}>{name} + }, + { + title: '触发事件', + dataIndex: 'eventName', + key: 'eventName', + align: 'center', + render: (eventName) => { + return {pipelineConfig?.find((item) => item.eventName == eventName)?.eventNameChinese} + } + }, + { + title: '结果', + dataIndex: 'success', + key: 'success', + align: 'center', + render: (success) => { + return success ? 成功 : 失败 + } + }, + { + title: "详情", + dataIndex: "detail", + key: "detail", + render: (_, record) => { + return { + Modal.info({ + title: '详情', + width: 800, + content:
+

脚本日志:

+
{record.logs.map(l => 

{l}

)}
+

输入:

+
{JSON.stringify(record.input, null, 2)}
+

输出:

+
{JSON.stringify(record.output, null, 2)}
+
+ }) + }}>详情
+ } + } + ]; + return ( + <> + { + // console.log(params); + // const data = await fetchData(); + const { data } = await getLog('runPipeline', params.current, params.pageSize); + return { + data: data.data, + // success 请返回 true, + // 不然 table 会停止解析数据,即使有数据 + success: true, + // 不传会使用 data 的长度,如果是分页一定要传 + total: data.total, + }; + }} + /> + + ); +} diff --git a/packages/admin/src/pages/Pipeline/components/PipelineModal.tsx b/packages/admin/src/pages/Pipeline/components/PipelineModal.tsx new file mode 100644 index 000000000..0ff8229c1 --- /dev/null +++ b/packages/admin/src/pages/Pipeline/components/PipelineModal.tsx @@ -0,0 +1,81 @@ +import { ModalForm, ProFormSelect, ProFormSwitch, ProFormText } from "@ant-design/pro-form"; + +import { getPipelineConfig, createPipeline, updatePipelineById } from "@/services/van-blog/api" +import { useEffect, useState } from "react"; +import { Form, message } from "antd"; +export default function ({ mode, trigger, initialValues, onFinish }: { + mode: "edit" | "create"; + trigger: any; + initialValues?: any; + onFinish: (vals: any) => void; +}) { + const isEdit = mode === "edit" + const [des, setDes] = useState('选择触发事件后讲展示详情'); + const [config, setConfig] = useState([]); + + const check = (vals: any) => { + const keys = Object.keys(vals); + const mustKeys = [ + 'name', + 'description', + 'eventName', + "enabled" + ]; + for (let i = 0; i < mustKeys.length; i++) { + if (!keys.includes(mustKeys[i])) { + return false; + } + } + return true; + } + + useEffect(() => { + if (!initialValues || !initialValues.eventName) return; + const targetDes = config.find(item => item.eventName === initialValues.eventName)?.eventDescription; + setDes(targetDes || '选择触发事件后讲展示详情') + + }, [initialValues, config]) + + return { + console.log(vals) + if (!check(vals)) { + message.error('请填写完整信息后提交!'); + return false; + } else { + if (mode == "create") { + await createPipeline(vals); + message.success('提交成功!'); + onFinish(vals); + return true; + } else { + await updatePipelineById(initialValues.id, vals); + message.success('提交成功!'); + onFinish(vals); + return true; + } + } + }} + layout="horizontal" + labelCol={{ span: 4 }} + initialValues={isEdit ? { ...initialValues } : { enabled: false }} + autoFocusFirstInput title={isEdit ? "修改流水线" : "创建流水线"}> + + + { + const { data } = await getPipelineConfig(); + setConfig(data); + return data?.map(item => ({ + label: item.eventNameChinese, + value: item.eventName + })) + }} fieldProps={{ + onChange: (val) => { + const targetDes = config.find(item => item.eventName === val)?.eventDescription; + setDes(targetDes || '选择触发事件后讲展示详情') + } + }} /> +
{des}
+ + +
+} diff --git a/packages/admin/src/pages/Pipeline/components/RunCodeModal.tsx b/packages/admin/src/pages/Pipeline/components/RunCodeModal.tsx new file mode 100644 index 000000000..f4a9a4d03 --- /dev/null +++ b/packages/admin/src/pages/Pipeline/components/RunCodeModal.tsx @@ -0,0 +1,47 @@ +import { checkJsonString } from "@/services/van-blog/checkJson"; +import { ModalForm, ProFormTextArea } from "@ant-design/pro-form"; +import { triggerPipelineById } from "@/services/van-blog/api" +import { message, Modal } from "antd"; + +export default function ({ pipeline, trigger }: { + pipeline: any; + trigger: any; +}) { + + + const runCode = async (input?: any) => { + const dto: any = {} + if (input) { + const inputObj = JSON.parse(input); + dto.input = inputObj; + } + const { data } = await triggerPipelineById(pipeline.id, dto); + Modal.info({ + title: '运行结果', + width: 800, + content:
{JSON.stringify(data, null, 2)}
+ }); + } + return { + if (!vals.input) { + runCode(); + return true; + } else { + if (!checkJsonString(vals.input)) { + message.error('请输入正确的json格式!'); + return false; + } else { + runCode(vals.input); + return true; + } + } + }} + + trigger={trigger}> + + + +} diff --git a/packages/admin/src/pages/Pipeline/index.tsx b/packages/admin/src/pages/Pipeline/index.tsx new file mode 100644 index 000000000..9d0be415a --- /dev/null +++ b/packages/admin/src/pages/Pipeline/index.tsx @@ -0,0 +1,123 @@ +import TipTitle from "@/components/TipTitle"; +import { PageContainer } from "@ant-design/pro-layout"; +import ProTable from "@ant-design/pro-table"; +import { Button, message, Modal, Space, Tag } from "antd"; +import { getPiplelines, getPipelineConfig, deletePipelineById } from "@/services/van-blog/api" +import PipelineModal from "./components/PipelineModal"; +import { useEffect, useRef, useState } from "react"; +import { history } from "umi"; + + +export default function () { + const [pipelineConfig, setPipelineConfig] = useState([]); + const actionRef = useRef(); + + useEffect(() => { + getPipelineConfig().then(({ data }) => { + setPipelineConfig(data); + }); + }, []) + + const columns = [ + { + dataIndex: "id", + valueType: "number", + title: 'ID', + width: 48, + }, + { + dataIndex: "name", + valueType: "text", + title: '名称', + width: 120, + }, + { + title: '是否异步', + width: 60, + render: (_, record) => { + const passive = pipelineConfig.find(item => item.eventName === record.eventName)?.passive || true; + return () + } + }, + { + dataIndex: "eventName", + valueType: "text", + title: '触发事件', + width: 120, + render: (eventName) => { + return pipelineConfig.find(item => item.eventName === eventName)?.eventNameChinese; + } + }, + { + dataIndex: "enabled", + title: '状态', + width: 60, + render: (enabled: boolean) => () + }, + { + title: '操作', + width: 180, + render: (_, record, action) => { + return (<> + + { + history.push('/code?type=pipeline&id=' + record.id) + }}>编辑脚本 + 修改信息} initialValues={record} onFinish={() => { + actionRef.current?.reload(); + }} /> + + { + Modal.confirm({ + title: '确定删除该流水线吗? ', + onOk: async () => { + await deletePipelineById(record.id); + console.log(action) + actionRef.current.reload(); + message.success("删除成功!") + } + }) + }}>删除 + + ) + } + } + ] + + return ( + ) + }} + > + { + return [ + 新建} onFinish={() => { + action.reload(); + }} />, + + ] + }} + headerTitle="流水线列表" + + columns={columns} search={false} rowKey="id" request={async () => { + const data = await getPiplelines(); + return { + data: data.data, + success: true, + total: data.data.length + } + + }} /> + + + ) +} diff --git a/packages/admin/src/services/van-blog/api.js b/packages/admin/src/services/van-blog/api.js index e927ea2b7..9b03c04ba 100644 --- a/packages/admin/src/services/van-blog/api.js +++ b/packages/admin/src/services/van-blog/api.js @@ -492,3 +492,62 @@ export async function getWelcomeData(tab, overviewNum = 5, viewNum = 5, articleT }, ); } +export async function getPiplelines() { + return request( + `/api/admin/pipeline`, + { + method: 'GET', + } + ) +} +export async function getPipelineConfig() { + return request( + `/api/admin/pipeline/config`, + { + method: 'GET', + } + ) +} +export async function getPipelineById(id) { + return request( + `/api/admin/pipeline/${id}`, + { + method: 'GET', + } + ) +} +export async function updatePipelineById(id,data) { + return request( + `/api/admin/pipeline/${id}`, + { + method: 'PUT', + data, + } + ) +} +export async function deletePipelineById(id) { + return request( + `/api/admin/pipeline/${id}`, + { + method: 'DELETE', + } + ) +} +export async function createPipeline(data) { + return request( + `/api/admin/pipeline`, + { + method: 'POST', + data, + } + ) +} +export async function triggerPipelineById(id,input) { + return request( + `/api/admin/pipeline/trigger/${id}`, + { + method: 'POST', + data: input, + } + ) +} diff --git a/packages/admin/src/services/van-blog/checkJson.ts b/packages/admin/src/services/van-blog/checkJson.ts new file mode 100644 index 000000000..3b9efd9c5 --- /dev/null +++ b/packages/admin/src/services/van-blog/checkJson.ts @@ -0,0 +1,8 @@ +export const checkJsonString = (s: string) => { + try { + JSON.parse(s); + return true; + } catch (e) { + return false; + } +} diff --git a/packages/admin/src/services/van-blog/event.ts b/packages/admin/src/services/van-blog/event.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/admin/jsconfig.json b/packages/admin/tsconfig.json similarity index 82% rename from packages/admin/jsconfig.json rename to packages/admin/tsconfig.json index 26dd5914f..6767f45c3 100644 --- a/packages/admin/jsconfig.json +++ b/packages/admin/tsconfig.json @@ -6,7 +6,9 @@ "baseUrl": ".", "composite": true, "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } } } diff --git a/packages/server/src/app.module.ts b/packages/server/src/app.module.ts index 09fc28be3..ab8aa9c1c 100644 --- a/packages/server/src/app.module.ts +++ b/packages/server/src/app.module.ts @@ -82,6 +82,9 @@ import { PublicCustomPageController, PublicOldCustomPageRedirectController, } from './controller/customPage/customPage.controller'; +import { Pipeline, PipelineSchema } from './scheme/pipeline.schema'; +import { PipelineProvider } from './provider/pipeline/pipeline.provider'; +import { PipelineController } from './controller/admin/pipeline/pipeline.controller'; @Module({ imports: [ @@ -100,6 +103,7 @@ import { { name: CustomPage.name, schema: CustomPageSchema }, { name: Token.name, schema: TokenSchema }, { name: Category.name, schema: CategorySchema }, + { name: Pipeline.name, schema: PipelineSchema }, ]), JwtModule.register({ secret: config.jwtSecret, @@ -136,6 +140,7 @@ import { CustomPageController, PublicCustomPageController, PublicOldCustomPageRedirectController, + PipelineController ], providers: [ AppService, @@ -172,6 +177,7 @@ import { TokenProvider, TokenGuard, WebsiteProvider, + PipelineProvider ], }) export class AppModule implements NestModule { diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index 5cb5cd881..7c2e9fac3 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -5,6 +5,8 @@ export interface Config { mongoUrl: string; jwtSecret: string; staticPath: string; + codeRunnerPath: string; + pluginRunnerPath: string; walineDB: string; demo: boolean | string; log: string; @@ -32,4 +34,6 @@ export const config: Config = { demo: loadConfig('demo', false), walineDB: loadConfig('waline.db', 'waline'), log: loadConfig('log', '/var/log'), + codeRunnerPath: loadConfig('codeRunner.path', '/app/codeRunner'), + pluginRunnerPath: loadConfig('pluginRunner.path', '/app/pluginRunner'), }; diff --git a/packages/server/src/controller/admin/article/article.controller.ts b/packages/server/src/controller/admin/article/article.controller.ts index 35fcf991f..aa9637ba6 100644 --- a/packages/server/src/controller/admin/article/article.controller.ts +++ b/packages/server/src/controller/admin/article/article.controller.ts @@ -17,8 +17,7 @@ import { SortOrder } from 'src/types/sort'; import { ArticleProvider } from 'src/provider/article/article.provider'; import { AdminGuard } from 'src/provider/auth/auth.guard'; import { ISRProvider } from 'src/provider/isr/isr.provider'; -import { UserProvider } from 'src/provider/user/user.provider'; -import { MetaProvider } from 'src/provider/meta/meta.provider'; +import { PipelineProvider } from 'src/provider/pipeline/pipeline.provider'; @ApiTags('article') @UseGuards(...AdminGuard) @Controller('/api/admin/article') @@ -26,8 +25,7 @@ export class ArticleController { constructor( private readonly articleProvider: ArticleProvider, private readonly isrProvider: ISRProvider, - private readonly userProvider: UserProvider, - private readonly metaProvider: MetaProvider, + private readonly pipelineProvider: PipelineProvider, ) {} @Get('/') @@ -83,10 +81,20 @@ export class ArticleController { message: '演示站禁止修改文章!', }; } + const result = await this.pipelineProvider.dispatchEvent('beforeUpdateArticle',updateDto); + if(result.length > 0){ + const lastResult = result[result.length - 1]; + const lastOuput = lastResult.output; + if (lastOuput) { + updateDto = lastOuput; + } + } const data = await this.articleProvider.updateById(id, updateDto); this.isrProvider.activeAll('更新文章触发增量渲染!', undefined, { postId: id, }); + const updatedArticle = await this.articleProvider.getById(id,'admin'); + this.pipelineProvider.dispatchEvent('afterUpdateArticle',updatedArticle) return { statusCode: 200, data, @@ -105,10 +113,19 @@ export class ArticleController { if (!createDto.author) { createDto.author = author; } + const result = await this.pipelineProvider.dispatchEvent("beforeUpdateArticle",createDto) + if(result.length > 0){ + const lastResult = result[result.length - 1]; + const lastOuput = lastResult.output; + if (lastOuput) { + createDto = lastOuput; + } + } const data = await this.articleProvider.create(createDto); this.isrProvider.activeAll('创建文章触发增量渲染!', undefined, { postId: data.id, }); + this.pipelineProvider.dispatchEvent("afterUpdateArticle",data) return { statusCode: 200, data, @@ -129,6 +146,9 @@ export class ArticleController { if (config.demo && config.demo == 'true') { return { statusCode: 401, message: '演示站禁止删除文章!' }; } + const toDeleteArticle = await this.articleProvider.getById(id,'admin'); + this.pipelineProvider.dispatchEvent("deleteArticle",toDeleteArticle) + const data = await this.articleProvider.deleteById(id); this.isrProvider.activeAll('删除文章触发增量渲染!', undefined, { postId: id, diff --git a/packages/server/src/controller/admin/auth/auth.controller.ts b/packages/server/src/controller/admin/auth/auth.controller.ts index 430d01dc4..786d8c710 100644 --- a/packages/server/src/controller/admin/auth/auth.controller.ts +++ b/packages/server/src/controller/admin/auth/auth.controller.ts @@ -19,6 +19,7 @@ import { LoginGuard } from 'src/provider/auth/login.guard'; import { TokenProvider } from 'src/provider/token/token.provider'; import { CacheProvider } from 'src/provider/cache/cache.provider'; import { InitProvider } from 'src/provider/init/init.provider'; +import { PipelineProvider } from 'src/provider/pipeline/pipeline.provider'; @ApiTags('tag') @Controller('/api/admin/auth/') @@ -30,6 +31,7 @@ export class AuthController { private readonly tokenProvider: TokenProvider, private readonly cacheProvider: CacheProvider, private readonly initProvider: InitProvider, + private readonly pipelineProvider: PipelineProvider, ) {} @UseGuards(LoginGuard, AuthGuard('local')) @@ -44,14 +46,16 @@ export class AuthController { } // 能到这里登陆就成功了 this.logProvider.login(request, true); + const data = await this.authProvider.login(request.user); + this.pipelineProvider.dispatchEvent('login', data) return { statusCode: 200, - data: await this.authProvider.login(request.user), + data , }; } @Post('/logout') - async logout(@Request() request: Request) { + async logout(@Request() request: any) { const token = request.headers['token']; if (!token) { throw new UnauthorizedException({ @@ -59,6 +63,9 @@ export class AuthController { message: '无登录凭证!', }); } + this.pipelineProvider.dispatchEvent('logout', { + token, + }) await this.tokenProvider.disableToken(token); return { statusCode: 200, diff --git a/packages/server/src/controller/admin/draft/draft.controller.ts b/packages/server/src/controller/admin/draft/draft.controller.ts index 4e4083211..9a9cf2796 100644 --- a/packages/server/src/controller/admin/draft/draft.controller.ts +++ b/packages/server/src/controller/admin/draft/draft.controller.ts @@ -21,6 +21,7 @@ import { AdminGuard } from 'src/provider/auth/auth.guard'; import { DraftProvider } from 'src/provider/draft/draft.provider'; import { ISRProvider } from 'src/provider/isr/isr.provider'; import { config } from 'src/config'; +import { PipelineProvider } from 'src/provider/pipeline/pipeline.provider'; @ApiTags('draft') @UseGuards(...AdminGuard) @@ -29,6 +30,7 @@ export class DraftController { constructor( private readonly draftProvider: DraftProvider, private readonly isrProvider: ISRProvider, + private readonly pipelineProvider: PipelineProvider, ) {} @Get('/') @@ -72,7 +74,17 @@ export class DraftController { @Put('/:id') async update(@Param('id') id: number, @Body() updateDto: UpdateDraftDto) { + const result = await this.pipelineProvider.dispatchEvent("beforeUpdateDraft",updateDto); + if(result.length > 0){ + const lastResult = result[result.length - 1]; + const lastOuput = lastResult.output; + if (lastOuput) { + updateDto = lastOuput; + } + } const data = await this.draftProvider.updateById(id, updateDto); + const updated = await this.draftProvider.findById(id); + this.pipelineProvider.dispatchEvent("afterUpdateDraft",updated) return { statusCode: 200, data, @@ -85,7 +97,16 @@ export class DraftController { if (!createDto.author) { createDto.author = author; } + const result = await this.pipelineProvider.dispatchEvent("beforeUpdateDraft",createDto); + if(result.length > 0){ + const lastResult = result[result.length - 1]; + const lastOuput = lastResult.output; + if (lastOuput) { + createDto = lastOuput; + } + } const data = await this.draftProvider.create(createDto); + this.pipelineProvider.dispatchEvent("afterUpdateDraft",data) return { statusCode: 200, data, @@ -99,8 +120,17 @@ export class DraftController { message: '演示站禁止发布草稿!', }; } + const result = await this.pipelineProvider.dispatchEvent("beforeUpdateArticle",publishDto) + if(result.length > 0){ + const lastResult = result[result.length - 1]; + const lastOuput = lastResult.output; + if (lastOuput) { + publishDto = lastOuput; + } + } const data = await this.draftProvider.publish(id, publishDto); this.isrProvider.activeAll('发布草稿触发增量渲染!'); + this.pipelineProvider.dispatchEvent("afterUpdateArticle",data) return { statusCode: 200, data, @@ -108,7 +138,9 @@ export class DraftController { } @Delete('/:id') async delete(@Param('id') id: number) { + const toDeleteDraft = await this.draftProvider.findById(id); const data = await this.draftProvider.deleteById(id); + this.pipelineProvider.dispatchEvent("deleteDraft",toDeleteDraft) return { statusCode: 200, data, diff --git a/packages/server/src/controller/admin/pipeline/pipeline.controller.ts b/packages/server/src/controller/admin/pipeline/pipeline.controller.ts new file mode 100644 index 000000000..b5b7bfbb0 --- /dev/null +++ b/packages/server/src/controller/admin/pipeline/pipeline.controller.ts @@ -0,0 +1,73 @@ +import { Body, Controller, Delete, Get, Param, Post, Put, Req, UseGuards } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AdminGuard } from 'src/provider/auth/auth.guard'; +import { Request } from 'express'; +import { PipelineProvider } from 'src/provider/pipeline/pipeline.provider'; +import { CreatePipelineDto } from 'src/types/pipeline.dto'; +import { VanblogSystemEvents } from 'src/types/event'; + +@ApiTags('pipeline') +@UseGuards(...AdminGuard) +@Controller('/api/admin/pipeline') +export class PipelineController { + constructor(private readonly pipelineProvider: PipelineProvider) {} + @Get() + async getAllPipelines(@Req() req: Request) { + const pipelines = await this.pipelineProvider.getAll(); + return { + statusCode: 200, + data: pipelines, + }; + } + @Get('config') + async getPipelineConfig(@Req() req: Request) { + return { + statusCode: 200, + data: VanblogSystemEvents, + }; + } + @Get('/:id') + async getPipelineById(@Param('id') idString: string) { + const id = parseInt(idString); + const pipeline = await this.pipelineProvider.getPipelineById(id); + return { + statusCode: 200, + data: pipeline, + }; + } + @Post() + async createPipeline(@Body() createPipelineDto: CreatePipelineDto) { + const pipeline = await this.pipelineProvider.createPipeline(createPipelineDto); + return { + statusCode: 200, + data: pipeline, + }; + } + @Delete('/:id') + async deletePipelineById(@Param('id') idString: string) { + const id = parseInt(idString); + const pipeline = await this.pipelineProvider.deletePipelineById(id); + return { + statusCode: 200, + data: pipeline, + }; + } + @Put('/:id') + async updatePipelineById(@Param('id') idString: string, @Body() updatePipelineDto: CreatePipelineDto) { + const id = parseInt(idString); + const pipeline = await this.pipelineProvider.updatePipelineById(id, updatePipelineDto); + return { + statusCode: 200, + data: pipeline, + }; + } + @Post('/trigger/:id') + async triggerPipelineById(@Param('id') idString: string, @Body() triggerDto: {input?: any}) { + const id = parseInt(idString); + const result = await this.pipelineProvider.triggerById(id,triggerDto.input); + return { + statusCode: 200, + data: result, + }; + } +} diff --git a/packages/server/src/controller/admin/site/site.meta.controller.ts b/packages/server/src/controller/admin/site/site.meta.controller.ts index bc632af73..937743564 100644 --- a/packages/server/src/controller/admin/site/site.meta.controller.ts +++ b/packages/server/src/controller/admin/site/site.meta.controller.ts @@ -7,6 +7,7 @@ import { MetaProvider } from 'src/provider/meta/meta.provider'; import { WalineProvider } from 'src/provider/waline/waline.provider'; import { config } from 'src/config'; import { WebsiteProvider } from 'src/provider/website/website.provider'; +import { PipelineProvider } from 'src/provider/pipeline/pipeline.provider'; @ApiTags('site') @UseGuards(...AdminGuard) @Controller('/api/admin/meta/site') @@ -16,6 +17,7 @@ export class SiteMetaController { private readonly isrProvider: ISRProvider, private readonly walineProvider: WalineProvider, private readonly websiteProvider: WebsiteProvider, + private readonly pipelineProvider: PipelineProvider ) {} @Get() @@ -36,6 +38,7 @@ export class SiteMetaController { }; } const data = await this.metaProvider.updateSiteInfo(updateDto); + this.pipelineProvider.dispatchEvent('updateSiteInfo',updateDto) this.isrProvider.activeAll('更新站点配置触发增量渲染!'); this.walineProvider.restart('更新站点,'); this.websiteProvider.restart('更新站点信息'); diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts index 3c3c0c221..3dca1fd94 100644 --- a/packages/server/src/main.ts +++ b/packages/server/src/main.ts @@ -25,6 +25,7 @@ async function bootstrap() { }); // 查看文件夹是否存在 并创建. + checkOrCreate(globalConfig.codeRunnerPath); checkOrCreate(globalConfig.staticPath); checkOrCreate(path.join(globalConfig.staticPath, 'img')); checkOrCreate(path.join(globalConfig.staticPath, 'tmp')); diff --git a/packages/server/src/provider/log/log.provider.ts b/packages/server/src/provider/log/log.provider.ts index 08794d452..a4d12bc75 100644 --- a/packages/server/src/provider/log/log.provider.ts +++ b/packages/server/src/provider/log/log.provider.ts @@ -8,6 +8,8 @@ import lineReader from 'line-reader'; import { config } from 'src/config'; import path from 'path'; import { checkOrCreate } from 'src/utils/checkFolder'; +import { Pipeline } from 'src/scheme/pipeline.schema'; +import { CodeResult } from '../pipeline/pipeline.provider'; @Injectable() export class LogProvider { logger = null; @@ -25,6 +27,19 @@ export class LogProvider { this.logger = pino({ level: 'debug' }, pino.multistream(streams)); this.logger.info({ event: 'start' }); } + async runPipeline(pipeline: Pipeline, input: any,result?:CodeResult, error?: Error) { + this.logger.info({ + event: EventType.RUN_PIPELINE, + pipelineId: pipeline.id, + pipelineName: pipeline.name, + eventName: pipeline.eventName, + success: result?.status == 'success' ? true : false, + logs: result?.logs || [], + output: result?.output || [], + serverError: error?.message || '', + input, + }) + } async login(req: Request, success: boolean) { const logger = this.logger; const { address, ip } = await getNetIp(req); diff --git a/packages/server/src/provider/log/types.ts b/packages/server/src/provider/log/types.ts index fb1d5c213..5f26c8997 100644 --- a/packages/server/src/provider/log/types.ts +++ b/packages/server/src/provider/log/types.ts @@ -4,4 +4,5 @@ export interface loginLog { export enum EventType { LOGIN = 'login', LOGOUT = 'logout', + RUN_PIPELINE = 'runPipeline', } diff --git a/packages/server/src/provider/pipeline/pipeline.provider.ts b/packages/server/src/provider/pipeline/pipeline.provider.ts new file mode 100644 index 000000000..6b91b765c --- /dev/null +++ b/packages/server/src/provider/pipeline/pipeline.provider.ts @@ -0,0 +1,263 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Logger } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { Model } from "mongoose"; +import { PipelineDocument } from "src/scheme/pipeline.schema"; +import { VanblogSystemEvent, VanblogSystemEventNames } from "src/types/event"; +import { CreatePipelineDto, UpdatePipelineDto } from "src/types/pipeline.dto"; +import { sleep } from "src/utils/sleep"; +import { spawnSync } from "child_process"; +import {config} from "src/config/index"; +import { writeFileSync,rmSync } from "fs"; +import { fork } from "child_process"; +import { LogProvider } from "../log/log.provider"; + +export interface CodeResult {logs: string[], output: any, status: "success" | "error"} + +@Injectable() +export class PipelineProvider { + logger = new Logger(PipelineProvider.name) + idLock = false; + runnerPath = config.codeRunnerPath; + constructor( + @InjectModel('Pipeline') + private pipelineModel: Model, + private readonly logProvider: LogProvider, + ) { + this.init(); + } + + + checkEvent(eventName: string) { + if (VanblogSystemEventNames.includes(eventName)) { + return true; + } + return false; + } + + async checkAllDeps() { + this.logger.log("初始化流水线代码库,这可能需要一段时间") + const pipelines = await this.getAll(); + const deps = []; + for (const pipeline of pipelines) { + for (const dep of pipeline.deps) { + if (!deps.includes(dep)) { + deps.push(dep); + } + } + } + await this.addDeps(deps); + } + + async saveAllScripts() { + const pipelines = await this.getAll(); + for (const pipeline of pipelines) { + await this.saveOrUpdateScriptToRunnerPath(pipeline.id, pipeline.script); + } + } + + async init() { + // 检查一遍,安装依赖 + this.checkAllDeps(); + await this.saveAllScripts(); + } + + async getNewId() { + while (this.idLock) { + await sleep(10); + } + this.idLock = true; + const maxObj = await this.pipelineModel.find({}).sort({ id: -1 }).limit(1); + let res = 1; + if (maxObj.length) { + res = maxObj[0].id + 1; + } + this.idLock = false; + return res; + } + + async createPipeline(pipeline: CreatePipelineDto) { + if (!this.checkEvent(pipeline.eventName)) { + throw new NotFoundException('Event not found in VanblogEventNames'); + } + const id = await this.getNewId(); + let script = pipeline.script; + if (!script || !script.trim()) { + script = ` +// 异步任务,请在脚本顶层使用 await,不然会直接被忽略 +// 请使用 input 变量获取数据(如果有) +// 直接修改 input 里的内容即可 +// 脚本结束后 input 将被返回 + +` + } + const newPipeline = await this.pipelineModel.create({ + id, + ...pipeline, + script + }); + await newPipeline.save(); + await this.saveOrUpdateScriptToRunnerPath(id, newPipeline.script); + await this.addDeps(newPipeline.deps); + } + + async updatePipelineById(id: number, updateDto: UpdatePipelineDto) { + await this.pipelineModel.updateOne({id: id}, updateDto); + if (updateDto.script) { + await this.saveOrUpdateScriptToRunnerPath(id, updateDto.script); + } + if (updateDto.deps) { + await this.addDeps(updateDto.deps); + } + } + + async deletePipelineById(id: number) { + await this.pipelineModel.updateOne({id: id},{ + deleted: true, + }); + await this.deleteScriptById(id); + } + async getAll() { + return await this.pipelineModel.find({ + deleted: false, + }); + } + + async getPipelineById(id: number) { + return await this.pipelineModel.findOne({id: id}); + } + + async getPipelinesByEvent(eventName: string) { + return await this.pipelineModel.find({ + eventName, + deleted: false, + }) + } + + async triggerById(id: number, data: any) { + const result = await this.runCodeByPipelineId(id, data); + return result; + } + + async dispatchEvent(eventName: VanblogSystemEvent, data?: any) { + const pipelines = await this.getPipelinesByEvent(eventName); + const results:CodeResult[] = []; + for (const pipeline of pipelines) { + if (pipeline.enabled) { + try { + const result = await this.runCodeByPipelineId(pipeline.id, data); + results.push(result); + } catch (e) { + this.logger.error(e); + } + } + } + return results; + } + + getPathById(id: number) { + return `${this.runnerPath}/${id}.js`; + } + + async runCodeByPipelineId (id: number, data: any): Promise { + + + const pipeline = await this.getPipelineById(id); + if (!pipeline) { + throw new NotFoundException('Pipeline not found'); + } + const traceId = new Date().getTime(); + this.logger.log(`[${traceId}]开始运行流水线: ${id} ${JSON.stringify(data,null,2)}`) + const run = new Promise((resolve, reject) => { + const subProcess = fork(this.getPathById(id)); + subProcess.send(data || {}); + subProcess.on('message', (msg: CodeResult) => { + if (msg.status === 'error') { + subProcess.kill('SIGINT'); + reject(msg); + } else { + resolve(msg); + } + }); + }); + try { + const result = await run as CodeResult; + this.logger.log(`[${traceId}]运行流水线成功: ${id} ${JSON.stringify(result,null,2)}`) + this.logProvider.runPipeline(pipeline,data,result); + return result; + } catch(err) { + this.logger.error(`[${traceId}]运行流水线失败: ${id} ${JSON.stringify(err,null,2)}`) + this.logProvider.runPipeline(pipeline,data,undefined, err); + throw err; + } + } + + + async addDeps(deps: string[]) { + + for (const dep of deps) { + try { + const r = spawnSync(`pnpm`,["add",dep],{ + cwd: this.runnerPath, + shell: process.platform === 'win32', + env: { + ...process.env, + } + }); + console.log(r.output.toString()) + } catch (e) { + // console.log(e.output.map(a => a.toString()).join('')); + console.log(e) + // this.logger.error(e); + } + } + } + + async deleteScriptById(id: number) { + const filePath = this.getPathById(id); + try { + rmSync(filePath); + }catch(err) { + this.logger.error(err); + } + } + + async saveOrUpdateScriptToRunnerPath(id: number, script: string) { + const filePath = this.getPathById(id); + const scriptToSave = ` + let input = {}; + let logs = []; + const oldLog = console.log; + console.log = (...args) => { + const logArr = []; + for (const each of args) { + if (typeof each === 'object') { + logArr.push(JSON.stringify(each,null,2)); + } else { + logArr.push(each); + } + } + logs.push(logArr.join(" ")); + oldLog(...args); + }; + process.on('message',async (msg) => { + input = msg; + try { + ${script} + process.send({ + status: 'success', + output: input, + logs, + }); + } catch(err) { + process.send({ + status: 'error', + output: err, + logs, + }); + } + }); + `; + writeFileSync(filePath, scriptToSave,{encoding: 'utf-8'}); + } +} diff --git a/packages/server/src/scheme/pipeline.schema.ts b/packages/server/src/scheme/pipeline.schema.ts new file mode 100644 index 000000000..22f5c8349 --- /dev/null +++ b/packages/server/src/scheme/pipeline.schema.ts @@ -0,0 +1,52 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; +import { VanblogEventType } from 'src/types/event'; +export type PipelineDocument = Pipeline & Document; + +@Schema() +export class Pipeline extends Document { + @Prop({ index: true, unique: true }) + id: number; + + @Prop({ index: true }) + name: string; + + @Prop({index: true}) + eventType: VanblogEventType + + @Prop({ index: true }) + description: string; + + @Prop({default: false}) + enabled: boolean; + + @Prop({default: []}) + deps: string[]; + + @Prop({ + index: true, + default: () => { + return new Date(); + }, + }) + createdAt: Date; + + @Prop({ + index: true, + default: () => { + return new Date(); + }, + }) + updatedAt: Date; + + @Prop({index:true}) + eventName: string; + + @Prop() + script: string; + + @Prop({default:false, index: true}) + deleted: boolean; +} + +export const PipelineSchema = SchemaFactory.createForClass(Pipeline); diff --git a/packages/server/src/types/event.ts b/packages/server/src/types/event.ts new file mode 100644 index 000000000..171efcb48 --- /dev/null +++ b/packages/server/src/types/event.ts @@ -0,0 +1,79 @@ +export interface VanblogEventItem { + eventName: string; + eventNameChinese: string; + eventDescription: string; + passive: boolean; +} + +export type VanblogEventType = "system" | "custom" | "corn" + +export const VanblogSystemEvents: VanblogEventItem[] = [ + { + eventName: 'login', + eventNameChinese: '登录', + eventDescription: '登录', + passive: true, + },{ + eventName: 'logout', + eventDescription: '登出', + eventNameChinese: '登出', + passive: true, + }, + { + eventName: 'beforeUpdateArticle', + eventNameChinese: '更新文章之前', + eventDescription: '更新文章之前,具体涉及到:发布草稿、保存文章、创建文章、更新文章信息,在此修改文章数据并返回会改变实际保存到数据库的值', + passive: false, + }, { + eventName: 'afterUpdateArticle', + eventNameChinese: '更新文章之后', + eventDescription: '更新文章之后,具体涉及到:发布草稿、保存文章、创建文章、更新文章信息', + passive: true, + },{ + eventName: 'deleteArticle', + eventNameChinese: '删除文章', + eventDescription: '删除文章', + passive: true, + }, + { + eventName: 'beforeUpdateDraft', + eventNameChinese: '更新草稿之前', + eventDescription: '更新草稿之前,具体涉及到:保存草稿、创建草稿、更新草稿信息,在此修改文章内容并返回会改变实际保存到数据库的文章内容', + passive: false, + }, { + eventName: 'afterUpdateDraft', + eventNameChinese: '更新草稿之后', + eventDescription: '更新草稿之后,具体涉及到:保存草稿、创建草稿、更新草稿信息', + passive: true, + },{ + eventName: 'deleteDraft', + eventNameChinese: '删除草稿', + eventDescription: '删除草稿', + passive: true, + },{ + eventName: "updateSiteInfo", + eventNameChinese: "更新站点信息", + eventDescription: "更新站点信息", + passive: true, + }, + { + eventName: "manualTriggerEvent", + eventNameChinese: "手动触发事件", + eventDescription: "手动触发事件事件", + passive: true, + } +] + +export const VanblogSystemEventNames = VanblogSystemEvents.map((item) => item.eventName) +export type VanblogSystemEvent = 'login' + | 'logout' + | 'beforeUpdateArticle' + | 'afterUpdateArticle' + | 'deleteArticle' + | 'beforeUpdateDraft' + | 'afterUpdateDraft' + | 'deleteDraft' + | 'updateSiteInfo' + | 'manualTriggerEvent' + + diff --git a/packages/server/src/types/pipeline.dto.ts b/packages/server/src/types/pipeline.dto.ts new file mode 100644 index 000000000..2d7192edc --- /dev/null +++ b/packages/server/src/types/pipeline.dto.ts @@ -0,0 +1,18 @@ +export class CreatePipelineDto { + name: string; + description?: string; + enabled: boolean; + eventName: string; + script: string; + deps?: string[]; +} + +export class UpdatePipelineDto { + name?: string; + description?: string; + enabled?: boolean; + eventName?: string; + script?: string; + deps?: string[]; + +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index bec6b886d..1f6af4625 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -11,7 +11,6 @@ "sourceMap": true, "outDir": "./dist", "baseUrl": "./", - "rootDir": "./src", "incremental": true, "skipLibCheck": true, "strictNullChecks": false, @@ -22,7 +21,7 @@ "composite": true, "paths": { "mongoose": [ - "node_modules/mongoose" + "./node_modules/mongoose" ], } }