diff --git a/README.md b/README.md index a2e6bee0..346523e2 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,15 @@ mysql -u root -p; CREATE DATABASE `think` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ``` +#### 可选:Redis + +如果需要文档版本服务,请在 `@think/config` 的 `yaml` 配置中进行 `db.redis` 的配置。 + +``` +docker pull redis:latest +docker run --name think-redis -p 6379:6379 -d redis --appendonly yes --requirepass "root" +``` + ### 本地运行 首先,clone 项目。 diff --git a/packages/client/src/components/document/editor/editor.tsx b/packages/client/src/components/document/editor/editor.tsx index 2b7c8035..c6e86462 100644 --- a/packages/client/src/components/document/editor/editor.tsx +++ b/packages/client/src/components/document/editor/editor.tsx @@ -21,7 +21,7 @@ import { DataRender } from 'components/data-render'; import { joinUser } from 'components/document/collaboration'; import { Banner } from 'components/banner'; import { debounce } from 'helpers/debounce'; -import { changeTitle } from './index'; +import { em, changeTitle, USE_DATA_VERSION } from './index'; import styles from './index.module.scss'; interface IProps { @@ -64,7 +64,7 @@ export const Editor: React.FC = ({ user, documentId, authority, classNam const title = transaction.doc.content.firstChild.content.firstChild.textContent; changeTitle(title); } catch (e) {} - }, 200), + }, 50), }); const [loading, toggleLoading] = useToggle(true); @@ -89,6 +89,16 @@ export const Editor: React.FC = ({ user, documentId, authority, classNam }; }, []); + useEffect(() => { + if (!editor) return; + const handler = (data) => editor.commands.setContent(data); + em.on(USE_DATA_VERSION, handler); + + return () => { + em.off(USE_DATA_VERSION, handler); + }; + }, [editor]); + return ( { em.emit(TITLE_CHANGE_EVENT, title); }; +const useVersion = (data) => { + em.emit(USE_DATA_VERSION, data); +}; + interface IProps { documentId: string; } @@ -89,6 +95,7 @@ export const DocumentEditor: React.FC = ({ documentId }) => { )} + }> + +
+
+
+ + {onSelect && ( + + )} +
+ + } + footer={null} + > + ( +
+ +
+
+ +
+
+
+ )} + /> +
+ + ); +}; diff --git a/packages/client/src/data/document.ts b/packages/client/src/data/document.ts index 3a85290f..ce1fdcdd 100644 --- a/packages/client/src/data/document.ts +++ b/packages/client/src/data/document.ts @@ -76,6 +76,21 @@ export const useDocumentDetail = (documentId, options = null) => { return { data, loading, error, update, toggleStatus }; }; +/** + * 获取文档历史版本 + * @param documentId + * @returns + */ +export const useDocumentVersion = (documentId) => { + const { data, error, mutate } = useSWR>( + `/document/version/${documentId}`, + (url) => HttpClient.get(url), + { errorRetryCount: 0 } + ); + const loading = !data && !error; + return { data: data || [], loading, error, refresh: mutate }; +}; + /** * 获取知识库最近更新的10条文档 * @returns diff --git a/packages/config/yaml/dev.yaml b/packages/config/yaml/dev.yaml index c489b460..612ff9e0 100644 --- a/packages/config/yaml/dev.yaml +++ b/packages/config/yaml/dev.yaml @@ -3,6 +3,8 @@ server: prefix: '/api' port: 5001 collaborationPort: 5003 + # 最大版本记录数 + maxDocumentVersion: 20 client: assetPrefix: '/' @@ -20,6 +22,10 @@ db: charset: 'utf8mb4' timezone: '+08:00' synchronize: true + redis: + host: '127.0.0.1' + port: '6379' + password: 'root' # oss 文件存储服务 oss: diff --git a/packages/server/package.json b/packages/server/package.json index e5361969..1e058851 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -38,6 +38,7 @@ "express": "^4.17.2", "express-rate-limit": "^6.2.0", "helmet": "^5.0.2", + "ioredis": "^5.0.1", "lodash": "^4.17.21", "mysql2": "^2.3.3", "nuid": "^1.1.6", diff --git a/packages/server/src/controllers/document.controller.ts b/packages/server/src/controllers/document.controller.ts index 1f5920be..93dc5eef 100644 --- a/packages/server/src/controllers/document.controller.ts +++ b/packages/server/src/controllers/document.controller.ts @@ -48,6 +48,14 @@ export class DocumentController { return await this.documentService.updateDocument(req.user, documentId, dto); } + @UseInterceptors(ClassSerializerInterceptor) + @Get('version/:id') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtGuard) + async getDocumentVersion(@Request() req, @Param('id') documentId) { + return await this.documentService.getDocumentVersion(req.user, documentId); + } + @UseInterceptors(ClassSerializerInterceptor) @Post('children') @HttpCode(HttpStatus.OK) diff --git a/packages/server/src/services/collaboration.service.ts b/packages/server/src/services/collaboration.service.ts index 711d22a5..7928b238 100644 --- a/packages/server/src/services/collaboration.service.ts +++ b/packages/server/src/services/collaboration.service.ts @@ -8,12 +8,13 @@ import * as lodash from 'lodash'; import { OutUser, UserService } from '@services/user.service'; import { TemplateService } from '@services/template.service'; import { DocumentService } from '@services/document.service'; +import { DocumentVersionService } from '@services/document-version.service'; @Injectable() export class CollaborationService { server: typeof Server; - debounceTime: 2000; - maxDebounceTime: 10000; + debounceTime = 1000; + maxDebounceTime = 10000; timers: Map< string, { @@ -28,12 +29,14 @@ export class CollaborationService { @Inject(forwardRef(() => DocumentService)) private readonly documentService: DocumentService, @Inject(forwardRef(() => TemplateService)) - private readonly templateService: TemplateService + private readonly templateService: TemplateService, + @Inject(forwardRef(() => DocumentVersionService)) + private readonly documentVersionService: DocumentVersionService ) { this.initServer(); } - debounce(id: string, func: () => void, immediately = false) { + debounce(id: string, func: () => void, debounceTime = this.debounceTime, immediately = false) { const old = this.timers.get(id); const start = old?.start || Date.now(); @@ -56,7 +59,7 @@ export class CollaborationService { this.timers.set(id, { start, - timeout: setTimeout(run, this.debounceTime), + timeout: setTimeout(run, debounceTime), }); } @@ -181,10 +184,19 @@ export class CollaborationService { const targetId = requestParameters.get('targetId'); const docType = requestParameters.get('docType'); + const updateDocument = async (user: OutUser, documentId: string, data) => { + await this.documentService.updateDocument(user, documentId, data); + this.debounce( + `onStoreDocumentVersion-${documentId}`, + () => { + this.documentVersionService.storeDocumentVersion(documentId, data.content); + }, + this.debounceTime * 2 + ); + }; + const updateHandler = - docType === 'document' - ? this.documentService.updateDocument.bind(this.documentService) - : this.templateService.updateTemplate.bind(this.templateService); + docType === 'document' ? updateDocument : this.templateService.updateTemplate.bind(this.templateService); this.debounce(`onStoreDocument-${targetId}`, () => { this.onStoreDocument(updateHandler, data).catch((error) => { diff --git a/packages/server/src/services/document-version.service.ts b/packages/server/src/services/document-version.service.ts new file mode 100644 index 00000000..a6d39bad --- /dev/null +++ b/packages/server/src/services/document-version.service.ts @@ -0,0 +1,100 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import Redis from 'ioredis'; +import { IDocument } from '@think/domains'; +import { getConfig } from '@think/config'; +import * as lodash from 'lodash'; + +@Injectable() +export class DocumentVersionService { + private redis: Redis; + private max: number = 0; + private error: string | null = '文档版本服务启动中'; + + constructor() { + this.init(); + } + + private versionDataToArray( + data: Record + ): Array<{ originVerison: string; version: string; data: string }> { + return Object.keys(data) + .sort((a, b) => +b - +a) + .map((key) => ({ originVerison: key, version: new Date(+key).toLocaleString(), data: data[key] })); + } + + private async init() { + const config = getConfig(); + const redisConfig = lodash.get(config, 'db.redis', null); + + if (!redisConfig) { + console.error('Redis 未配置,无法启动文档版本服务'); + return; + } + + this.max = lodash.get(config, 'server.maxDocumentVersion', 0); + + try { + const redis = new Redis({ + ...redisConfig, + db: 0, + showFriendlyErrorStack: true, + lazyConnect: true, + }); + redis.on('ready', () => { + console.log('文档版本服务启动成功'); + this.redis = redis; + this.error = null; + }); + redis.on('error', (e) => { + console.error(`Redis 启动失败: "${e}"`); + }); + redis.connect().catch((e) => { + this.redis = null; + this.error = 'Redis 启动失败:无法提供文档版本服务'; + }); + } catch (e) {} + } + + /** + * 根据 max 删除多余缓存数据 + * @param key + * @returns + */ + public async checkCacheLength(documentId) { + if (this.max <= 0) return; + const res = await this.redis.hgetall(documentId); + if (!res) return; + const data = this.versionDataToArray(res); + + while (data.length > this.max) { + const lastVersion = data.pop().originVerison; + await this.redis.hdel(documentId, lastVersion); + } + } + + /** + * 保存文档版本数据 + * @param documentId + * @param data + * @returns + */ + public async storeDocumentVersion(documentId: IDocument['id'], data: IDocument['content']) { + if (!this.redis) return; + const version = '' + Date.now(); + await this.redis.hsetnx(documentId, version, data); + await this.checkCacheLength(documentId); + } + + /** + * 获取文档版本数据 + * @param documentId + * @returns + */ + public async getDocumentVersions(documentId: IDocument['id']): Promise> { + if (this.error || !this.redis) { + throw new HttpException(this.error, HttpStatus.NOT_IMPLEMENTED); + } + const res = await this.redis.hgetall(documentId); + return res ? this.versionDataToArray(res) : []; + } +} diff --git a/packages/server/src/services/document.service.ts b/packages/server/src/services/document.service.ts index 5469192b..b116d09a 100644 --- a/packages/server/src/services/document.service.ts +++ b/packages/server/src/services/document.service.ts @@ -9,6 +9,7 @@ import { OutUser, UserService } from '@services/user.service'; import { WikiService } from '@services/wiki.service'; import { MessageService } from '@services/message.service'; import { CollaborationService } from '@services/collaboration.service'; +import { DocumentVersionService } from '@services/document-version.service'; import { TemplateService } from '@services/template.service'; import { ViewService } from '@services/view.service'; import { array2tree } from '@helpers/tree.helper'; @@ -53,6 +54,7 @@ const DOCUMENT_PLACEHOLDERS = [ @Injectable() export class DocumentService { private collaborationService: CollaborationService; + private documentVersionService: DocumentVersionService; constructor( @InjectRepository(DocumentAuthorityEntity) @@ -70,7 +72,13 @@ export class DocumentService { @Inject(forwardRef(() => ViewService)) private readonly viewService: ViewService ) { - this.collaborationService = new CollaborationService(this.userService, this, this.templateService); + this.documentVersionService = new DocumentVersionService(); + this.collaborationService = new CollaborationService( + this.userService, + this, + this.templateService, + this.documentVersionService + ); } /** @@ -494,6 +502,32 @@ export class DocumentService { return { document: { ...doc, views, createUser }, authority }; } + /** + * 获取文档历史版本 + * @param user + * @param documentId + * @returns + */ + public async getDocumentVersion(user: OutUser, documentId: string) { + const document = await this.documentRepo.findOne(documentId); + + if (!document) { + throw new HttpException('文档不存在', HttpStatus.NOT_FOUND); + } + + const authority = await this.documentAuthorityRepo.findOne({ + documentId, + userId: user.id, + }); + + if (!authority || !authority.readable) { + throw new HttpException('您无权查看此文档', HttpStatus.FORBIDDEN); + } + + const data = await this.documentVersionService.getDocumentVersions(documentId); + return data; + } + /** * 分享(或关闭分享)文档 * @param id diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3e01787..5b14fff8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -243,6 +243,7 @@ importers: express: ^4.17.2 express-rate-limit: ^6.2.0 helmet: ^5.0.2 + ioredis: ^5.0.1 jest: ^27.2.5 lodash: ^4.17.21 mysql2: ^2.3.3 @@ -286,6 +287,7 @@ importers: express: 4.17.2 express-rate-limit: 6.2.0_express@4.17.2 helmet: 5.0.2 + ioredis: 5.0.1 lodash: 4.17.21 mysql2: 2.3.3 nuid: 1.1.6 @@ -295,7 +297,7 @@ importers: reflect-metadata: 0.1.13 rimraf: 3.0.2 rxjs: 7.5.2 - typeorm: 0.2.41_mysql2@2.3.3 + typeorm: 0.2.41_ioredis@5.0.1+mysql2@2.3.3 ua-parser-js: 1.0.2 y-prosemirror: 1.0.14_8fd72c89aecefb95d86a797b5207d945 yjs: 13.5.24 @@ -945,6 +947,10 @@ packages: resolution: {integrity: sha512-YRsVFWjL8Gkkvlx3qnjeaxW4fnibSJ9791g8BA7Pv5ANByI64WmtR1vU7A2rXcrOn8XvyCEfY0ss1s8NhZP+MA==} dev: false + /@ioredis/commands/1.1.1: + resolution: {integrity: sha512-fsR4P/ROllzf/7lXYyElUJCheWdTJVJvOTps8v9IWKFATxR61ANOlnoPqhH099xYLrJGpc2ZQ28B3rMeUt5VQg==} + dev: false + /@istanbuljs/load-nyc-config/1.1.0: resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -1367,7 +1373,7 @@ packages: '@nestjs/core': 8.2.6_2d4ee36c4446df4873bb38fb1c4583da reflect-metadata: 0.1.13 rxjs: 7.5.2 - typeorm: 0.2.41_mysql2@2.3.3 + typeorm: 0.2.41_ioredis@5.0.1+mysql2@2.3.3 uuid: 8.3.2 dev: false @@ -2997,6 +3003,11 @@ packages: engines: {node: '>=6'} dev: false + /cluster-key-slot/1.1.0: + resolution: {integrity: sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==} + engines: {node: '>=0.10.0'} + dev: false + /co-defer/1.0.0: resolution: {integrity: sha1-Pkp4eo7tawoh7ih8CU9+jeDTyBg=} engines: {node: '>= 0.11.14'} @@ -3313,7 +3324,6 @@ packages: optional: true dependencies: ms: 2.1.2 - dev: true /decamelize-keys/1.1.0: resolution: {integrity: sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=} @@ -4445,6 +4455,23 @@ packages: loose-envify: 1.4.0 dev: false + /ioredis/5.0.1: + resolution: {integrity: sha512-/5MF5vnUQf1o3+sy/pnkce5K8zdSMTcTgpRUW5oGGT+mEsgd1r7YLRPZ+zWGb9hbZXE3BJCgphc2eESm+2d9KA==} + engines: {node: '>=12.22.0'} + dependencies: + '@ioredis/commands': 1.1.1 + cluster-key-slot: 1.1.0 + debug: 4.3.4 + denque: 2.0.1 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + dev: false + /ip/1.1.5: resolution: {integrity: sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=} dev: false @@ -5453,10 +5480,18 @@ packages: p-locate: 4.1.0 dev: true + /lodash.defaults/4.2.0: + resolution: {integrity: sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=} + dev: false + /lodash.includes/4.3.0: resolution: {integrity: sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=} dev: false + /lodash.isarguments/3.1.0: + resolution: {integrity: sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=} + dev: false + /lodash.isboolean/3.0.3: resolution: {integrity: sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=} dev: false @@ -6780,6 +6815,18 @@ packages: strip-indent: 3.0.0 dev: true + /redis-errors/1.2.0: + resolution: {integrity: sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=} + engines: {node: '>=4'} + dev: false + + /redis-parser/3.0.0: + resolution: {integrity: sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=} + engines: {node: '>=4'} + dependencies: + redis-errors: 1.2.0 + dev: false + /reflect-metadata/0.1.13: resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==} dev: false @@ -7213,6 +7260,10 @@ packages: escape-string-regexp: 2.0.0 dev: true + /standard-as-callback/2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + dev: false + /statuses/1.5.0: resolution: {integrity: sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=} engines: {node: '>= 0.6'} @@ -7905,7 +7956,7 @@ packages: resolution: {integrity: sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=} dev: false - /typeorm/0.2.41_mysql2@2.3.3: + /typeorm/0.2.41_ioredis@5.0.1+mysql2@2.3.3: resolution: {integrity: sha512-/d8CLJJxKPgsnrZWiMyPI0rz2MFZnBQrnQ5XP3Vu3mswv2WPexb58QM6BEtmRmlTMYN5KFWUz8SKluze+wS9xw==} hasBin: true peerDependencies: @@ -7964,6 +8015,7 @@ packages: debug: 4.3.3 dotenv: 8.6.0 glob: 7.2.0 + ioredis: 5.0.1 js-yaml: 4.1.0 mkdirp: 1.0.4 mysql2: 2.3.3