最近在做文档管理的相关业务,发现挺有意思,刚开始想的很复杂,后来发现好像没有那么繁琐,在这里把使用的技术点以及遇到的问题做一个整体回顾。
技术选型:
前端:vue2+monaco-editor
服务端:Nest.js+Gitlab API+Mysql+CI
前端涉及编辑器的相关插件以及包如下:
- markdown-it
- markdown-it-anchor
- markdown-it-link-attributes
- markdown-it-toc-done-right
- monaco-editor-webpack-plugin
前端部分后面会专门通过一篇文章来讲,这里主要围绕后端展开来说。
这里的文档管理功能相对来说比较简易,主要包括文档列表(增删改查)、文档版本列表(增删改查、构建、构建记录、封版)、文档编辑页面(左侧文档目录树、右侧文档内容(样式编辑、文档编辑、多语言))
数据库设计
文档列表
文档列表内容直接入库,主要包括新增、编辑、删除文档操作。创建一个文档,相当于创建一个git仓库(基于一个模版仓库进行fork操作),模版仓库文件目录结构如下所示:
├── zh-cn
| ├── menu.json # 文档菜单
├── .gitlab-ci.yml # 构建CI
├── manifest.json # 文档基础信息
└── style.css # 文档样式
文档列表字段主要包括名称、描述、文档版本、是否支持多语言、创建人、修改者、创建时间、修改时间。
文档版本列表
新增一个版本即在git中对应仓库创建一个分支。文档版本列表字段主要包括名称、描述、语言版本、创建人、修改者、创建时间、修改时间。列表操作项中构建记录和Gitlab CI关联,点击构建按钮,会触发CI自动执行。
构建流程
技术选型:Gitlab CI+Node脚本
获取构建记录时,会查询对应文档指定tag的CI执行状态,如果ci执行完成,更新数据库中状态以及构建包url
文档下载
在构建记录列表中可以对某个版本的文档进行下载,下载解压后获取的文档目录结构如下:
├── zh-cn
| ├── html
| | ├── uuid.html # 单个文档
| ├── menu.json # 文档菜单
├── manifest.json # 文档基础信息
└── style.css # 文档样式
文档编辑
技术选型:Gitlab API
文档编辑页面相关文档内容操作基本都是通过Gitlab API完成
文档编辑页面分为两部分:
- 左侧:文档目录树(增删改查)
- 右侧:文档编辑区(主要包括图片上传、word转换为md内容,编辑单个文档后点击保存实时推送git(没有则创建文件,有则更新文件))
最终git上文档目录结构如下所示:
├── zh-cn
| ├── docs
| | ├── uuid.md # 单个文档
| ├── menu.json # 文档菜单
├── .gitlab-ci.yml # 构建CI
├── manifest.json # 文档基础信息
└── style.css # 文档样式
图片上传
# controller
import {
UseInterceptors,
UploadedFiles,
...
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
@Post('/doc/pic')
@UseInterceptors(FilesInterceptor('files'))
async uploadPic(
@UploadedFiles() files: Array<Express.Multer.File>
) {
return await this.xxx.uploadPic(files);
}
# service
async uploadPic(files) {
let result = [];
await Promise.all(
(result = files.map((file) => {
const index = file.mimetype.lastIndexOf('/');
const fileSuffix = file.mimetype.substring(index + 1);
const fileUuid = uuidv4();
// 解决图片名称是中文时接口返回乱码问题
file.originalname = Buffer.from(file.originalname, 'latin1').toString(
'utf8',
);
return {
name: file.originalname,
uuid: fileUuid,
fileSuffix,
};
})),
);
return result;
}
在做图片上传时遇到了图片名称如果是中文时,接口返回乱码(postman返回正常),解决方案是通过Buffer进行处理,具体操作如下:
// 解决图片名称是中文时接口返回乱码问题
file.originalname = Buffer.from(file.originalname, 'latin1').toString(
'utf8',
);
一般在遇到中文乱码问题时,首先查看response-headers中的Content-type是否有设置utf-8,如果没有则在response-headers设置Content-Type: application/json; charset=utf-8,然后再排查其他原因,不过这种方法用postman测试时返回的是乱码,原因是(postman发送请求的时候,会额外增加一个filename,位于Content-Disposition中),这里就不展开说了,感兴趣的同学可以查看这篇文章:cnodejs.org/topic/5e685…
图片下载
直接下载
# controller
@Get('/download')
async download(@Res() res: Response) {
return this.xxx.downloadFile(res, id);
}
# service
async downloadFile(res) {
res.download(zip, (err) => {
if (!err) {
console.log('success', 'download', zip);
return;
}
res.send({
code: 400,
error: err,
detail: String(err),
data: null,
});
});
}
流的形式下载
下载后的文件是二进制形式,需要前端一起配合
@Get('/download')
async download(@Res() res: Response) {
const tarStream = new zip.Stream()
await tarStream.addEntry(url)
res.setHeader('Content-Type','application/octet-stream')
res.setHeader('Content-Disposition','attachment;filename=xx')
tarStream.pipe(res)
}
word转换为md内容
技术选型:turndown+mammoth
# controller
import { FileInterceptor } from '@nestjs/platform-express';
import {
UploadedFile,
UseInterceptors,
...
} from '@nestjs/common';
@Post('/word-to-doc')
@UseInterceptors(FileInterceptor('file'))
async wordToDoc(@UploadedFile() file) {
return await this.xxx.wordToDoc(file);
}
# service
const TurndownService = require('turndown');
const turndownService = new TurndownService();
async wordToDoc(file) {
const rootDir = process.cwd();
// 创建docx临时目录
child.execSync(`cd ${rootDir} && mkdir temp && cd temp && mkdir doc`);
// 创建docx中图片临时目录
child.execSync(`cd ${rootDir}/temp/doc && mkdir image && cd image`);
const docPath = `${rootDir}/temp/doc/${uuidv4()}.docx`;
// 写入temp
fs.writeFileSync(docPath, file.buffer);
const html = await docx2html(docPath); // docx to html
const markdown = turndownService.turndown(html); // html to markdown
// 删除temp临时文件
child.execSync(`cd ${rootDir}/ && rm -rf temp`);
return markdown;
}
# common.js
import { images, convertToHtml } from 'mammoth';
export const docx2html = (filepath) => {
return new Promise((resolve, reject) => {
convertToHtml({ path: filepath }, mammothOptions)
.then((result) => {
resolve(result.value);
})
.catch((err) => reject(err));
});
};
// 图片格式
const MimeTypeObj = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/svg+xml': '.svg',
};
const mammothOptions = {
convertImage: images.imgElement(function (image) {
return image.read().then((buffer) => {
const img = {
buffer,
suffix: MimeTypeObj[image.contentType],
// originalname: '',
};
// TODO 处理图片,上传到图床获取url地址
return {
src: url,
};
});
}),
};
问题
服务器上Clone with SSH时权限问题
解决方案:通过token处理(Clone with HTTPS)
具体实现如下:
/**
* 功能:拉取git仓库指定分支到workspace
* @param docName git仓库名称
* @param branchName git仓库分支名称
*/
export const gitCloneRepository = (path, docName, versionName) => {
const gitPath = `https://oauth2:${GITLAB_TOKEN}@gitxxx/${DOCS_GIT_WORKSPACE}/${docName}.git`;
child.execSync(`cd ${path} && git clone -b ${versionName} ${gitPath}`);
console.log(`${docName} 克隆成功`);
};
Nest.js服务启动后没有绿色日志信息
原因:因为中途添加了日志信息,里面没有log导致
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log'], // 即必须有log,否则服务启动后没有相关日志信息
});
...
}
Nest.js图片上传部署服务器后报错
报错信息:
通过错误可知报错来源于<Express.Multer.File>部分
解决方案: 代码中添加如下代码:
import { Express } from 'express' 导入这个类型
mysql报错
报错信息:
解决方案:重启mysql容器
Gitlab API 使用常见问题
- git创建文件不支持并发执行,需要线性执行
- fork仓库时,参数path必须传,否则调用api会报错,path是git仓库名称
- 方法参数名称以及顺序有的和Gitlab API不是完全一样的
Linux问题
在使用脚本批量迁移历史文档信息时,发现linux命令对git以及对中英文不敏感,需要特殊处理。即git忽略大小写,命令操作时涉及大小写时需要单独处理。
树形结构增删改查
左侧文档目录展示形式是树形结构,主要操作包括增删改查
新增节点
/**
* 功能:新增节点
* @param docs
* @param docsMap
* @returns
*/
export const appendNodeInTree = (docs, docsMap) => {
if (docsMap.pid) {
docs.forEach((ele) => {
if (ele.uuid === docsMap.pid) {
// 文档目录插入子节点
ele.doc_ids.push(docsMap.uuid);
ele.children.push(docsMap);
} else {
if (ele.children) {
appendNodeInTree(ele.children, docsMap);
}
}
});
} else {
// 最外层目录插入
docs.push(docsMap);
}
return docs;
};
// 使用
appendNodeInTree(docs, docContent);
修改节点
/**
* 功能:修改tree节点
* @param {*} list 需要更新的数组
* @param {*} uuid 节点唯一标识
* @param {*} pid 父级标识
* @param {*} obj 修改内容
*/
export const updateNodeInTree = (list, uuid, pid, obj) => {
if (!list || !list.length) {
return;
}
for (let i = 0; i < list.length; i++) {
const temp = Object.assign({}, list[i], {
...obj,
last_modify_at: new Date(),
});
if (pid) {
if (list[i].uuid == uuid && list[i].pid == pid) {
list[i] = temp;
break;
}
} else {
if (list[i].uuid == uuid) {
list[i] = temp;
break;
}
}
updateNodeInTree(list[i].children, uuid, pid, obj);
}
};
// 使用
updateNodeInTree(docs, uuid, pid, modifyContent);
删除节点
/**
* 功能:删除子节点
* @param list 需要更新的数组
* @param uuid 唯一标识
* @param pid 父级唯一标识
* @returns
*/
export const removeNodeInTree = (list, uuid, pid) => {
if (!list || !list.length) {
return;
}
// 有pid的话删除doc_ids
for (let i = 0; i < list.length; i++) {
if (pid && list[i].uuid === pid) {
const index = list[i].doc_ids.findIndex((itm) => {
itm === uuid;
});
list[i].doc_ids.splice(index, 1);
}
if (list[i].uuid === uuid) {
list.splice(i, 1);
break;
}
removeNodeInTree(list[i].children, uuid, pid);
}
};
// 使用
removeNodeInTree(docs, uuid, pid);
查看节点
/**
* 功能:查找节点
* @param data
* @param key
* @param value
* @param callback
* @returns
*/
export const findNodeInTree = (data, key, value, callback) => {
for (let i = 0; i < data.length; i++) {
if (data[i][key] == value) {
return callback(data[i], key, value, callback);
}
if (data[i].children) {
findNodeInTree(data[i].children, key, value, callback);
}
}
};
// 使用
// 获取当前节点
let treeNode;
findNodeInTree(docs, 'uuid', uuid, (item) => {
treeNode = item;
});
Gitlab API 使用以及常用方法
技术选型:@gitbeaker/node
@gitbeaker/node是GitLab API NodeJS库,完全支持所有Gitlab API服务
使用
// gitlab-repository.service
import { Gitlab } from '@gitbeaker/node';
@Injectable()
export class GitlabRepositoryService {
private api = new Gitlab({
host: process.env.GITLAB_HOST,
token: process.env.GITLAB_TOKEN,
version: 4,
rejectUnauthorized: false,
});
async createRepository(docName: string) {
await this.api.Projects.fork(31880, {
name: docName,
namespace_id: process.env.GIT_DOC_TEST_NAMESPACE,
path: docName, // 必须传,否则会报错
});
}
...
}
// service中使用,比如我要在product.service.ts中使用
1. product.module.ts
import { GitlabRepositoryService } from './gitlab-repository.service';
const rs: FactoryProvider = {
provide: GitlabRepositoryService,
useFactory: () => new GitlabRepositoryService(),
};
@Module({
...
providers: [rs],
exports: [rs],
})
2. product.service.ts
import { GitlabRepositoryService } from './gitlab-repository.service';
export class ProductService {
constructor(
private readonly gitlabRepositoryService: GitlabRepositoryService,
) {}
await this.gitlabRepositoryService.createRepository(
xxx,
);
}
常用方法
fork仓库
async createRepository(docName: string) {
try {
// fork
const res = await this.api.Projects.fork(31880, {
name: docName,
namespace_id: process.env.NAMESPACE,
path: docName, // 必须传,否则会报错
});
return res.id;
} catch (err) {
throw new InternalServerErrorException(
`创建Git仓库(${docName})失败: ${get(
err,
'description',
'未知错误',
)}`,
);
}
}
创建仓库文件
async createRepositoryFile(
gitlabId: number,
filePath: string,
branch: string,
content: string,
commitMessage: string,
) {
try {
return await this.api.RepositoryFiles.create(
gitlabId,
filePath,
branch,
content,
commitMessage,
);
} catch (error) {
// TODO 捕获异常
}
}
// 使用
await this.gitlabRepositoryService.createRepositoryFile(
product.gitlabId,
`${locale}/docs/${uuid}.md`,
version.name,
content,
commitMessage,
);
修改仓库名称
async updateRepository(gitlabId: number, docName: string) {
try {
await this.api.Projects.edit(gitlabId, { name: docName });
} catch (error) {
// TODO 捕获异常
}
}
删除仓库
async deleteRepository(gitlabId: number) {
try {
await this.api.Projects.remove(gitlabId);
} catch (error) {
// TODO 捕获异常
}
}
创建分支
// ref基于哪个分支创建
async createBranch(gitlabId: number, branchName: string, ref: string) {
try {
await this.api.Branches.create(gitlabId, branchName, ref);
} catch (error) {
// TODO 捕获异常
}
}
删除远程仓库分支
async deleteBranch(gitlabId: number, branchName: string) {
try {
await this.api.Branches.remove(gitlabId, branchName);
} catch (error) {
// TODO 捕获异常
}
}
获取git上文件内容
async getGitFile(filePath: string, gitlabId: number, ref: string) {
try {
const result = await this.api.RepositoryFiles.showRaw(
gitlabId,
filePath,
{
ref, // 分支、标记或提交的名称
},
);
return result;
} catch (error) {
// TODO 捕获异常
}
}
修改git上文件内容
async updateGitFile(
gitlabId: number,
commitMessage: string,
branchName: string,
filePath: string,
content: string,
) {
try {
return await this.api.RepositoryFiles.edit(
gitlabId,
filePath,
branchName,
content,
commitMessage,
);
} catch (error) {
// TODO 捕获异常
}
}
获取项目中存储库提交的列表
async getGitRepositoryCommits(
gitlabId: number,
branchName: string,
path?: string,
) {
try {
return await this.api.Commits.all(gitlabId, {
ref_name: branchName,
perPage: 1,
maxPages: 20,
path,
});
} catch (error) {
// TODO 捕获异常
}
}
获取项目成员角色
async getUserGitInfo(gitlabId: number, userId: number) {
try {
return await this.api.ProjectMembers.show(gitlabId, userId, {
includeInherited: true,
});
} catch (error) {
const message = `${get(error, 'description')}`;
if (message === '404 Not found') {
// 没有权限的用户
return {
access_level: null,
};
} else {
// TODO 捕获异常
}
}
}
列出组或项目的所有成员
async getGitGroupMember(
gitlabId: number,
query?: string,
includeInherited?: boolean,
) {
try {
return await this.api.ProjectMembers.all(gitlabId, {
includeInherited, // 是否继承
query, // 查询者
});
} catch (error) {
// TODO 捕获异常
}
}
添加项目成员
async addProjectMember(
projectId: number,
userId: number,
accessLevel,
username: string,
) {
try {
return await this.api.ProjectMembers.add(
projectId,
userId,
accessLevel,
);
} catch (e) {
// TODO 捕获异常
}
}
编辑项目的成员
async editProjectMember(
projectId: number,
userId: number,
accessLevel,
username: string,
) {
try {
return await this.api.ProjectMembers.edit(
projectId,
userId,
accessLevel,
);
} catch (e) {
// TODO 捕获异常
}
}
删除项目成员
async deleteProjectMember(projectId: number, userId: number) {
try {
return await this.api.ProjectMembers.remove(projectId, userId);
} catch (e) {
// TODO 捕获异常
}
}
获取用户信息
async getUserInfo(username: string) {
try {
const gitUsername = username.toLowerCase();
const userInfos = await this.api.Users.search(gitUsername);
return (userInfos as any[]).find((user) => user.username === gitUsername);
} catch (e) {
// TODO 捕获异常
}
}
基于分支创建tag
async createGitLabTagByBranch(
gitlabId: number,
tagName: string,
branchName: string,
message?: string,
) {
try {
return await this.api.Tags.create(gitlabId, tagName, branchName, {
message,
});
} catch (e) {
// TODO 捕获异常
}
}
获取项目tag列表
async getGitLabTags(gitlabId: number) {
try {
return await this.api.Tags.all(gitlabId);
} catch (e) {
// TODO 捕获异常
}
}
获取项目pipeline
async getDocVersionPipline(
gitlabId: number,
branchName: string,
ref: string,
) {
try {
return await this.api.Pipelines.all(gitlabId, {
scope: 'tags',
// page: 1,
// perPage: 10,
ref,
});
} catch (e) {
// TODO 捕获异常
}
}