├── prometheus.yml ├── .husky └── pre-commit ├── .swcrc ├── .npmrc ├── .prettierignore ├── .dockerignore ├── src ├── utils │ ├── date.ts │ ├── index.ts │ ├── constant.ts │ ├── utils.ts │ ├── data-type.ts │ └── file.ts ├── common │ ├── enum │ │ ├── notification-type.enum.ts │ │ ├── socket.ts │ │ └── config.enum.ts │ ├── logs │ │ ├── logs.module.ts │ │ └── logs.service.ts │ ├── email │ │ ├── email.module.ts │ │ └── email.service.ts │ ├── tasks │ │ ├── tasks.module.ts │ │ └── tasks.service.ts │ ├── types │ │ └── index.ts │ ├── redis │ │ ├── redis.module.ts │ │ └── redis.service.ts │ ├── fastify │ │ └── index.ts │ └── dto │ │ └── response.dto.ts ├── core │ ├── exceptions │ │ ├── login.exception.ts │ │ └── validation.exception.ts │ ├── decorate │ │ ├── user.decorator.ts │ │ ├── minio-url.decorator.ts │ │ ├── api-response.decorator.ts │ │ ├── match.decorator.ts │ │ └── upload.decorators.ts │ ├── events │ │ └── friend-request.events.ts │ ├── filter │ │ ├── WebsocketExceptions.filter.ts │ │ ├── http-exception.filter.ts │ │ └── all-exception.filter.ts │ ├── interceptor │ │ ├── timeout.interceptor.ts │ │ └── transform.interceptor.ts │ └── guard │ │ └── auth.guard.ts ├── api │ ├── code-questions │ │ ├── code-questions.controller.ts │ │ ├── schema │ │ │ └── code-questions.schema.ts │ │ ├── code-questions.service.ts │ │ └── code-questions.module.ts │ ├── collaborate-doc │ │ ├── dto │ │ │ └── collaborate-doc.dto.ts │ │ ├── schema │ │ │ └── collaborate-doc.schema.ts │ │ ├── collaborate-doc.module.ts │ │ ├── collaborate-doc.controller.ts │ │ ├── collaborate-doc.gateway.ts │ │ └── collaborate-doc.service.ts │ ├── cache-store │ │ ├── cache-store.module.ts │ │ └── cache-store.service.ts │ ├── auth │ │ ├── auth.service.spec.ts │ │ ├── auth.controller.spec.ts │ │ ├── auth.module.ts │ │ ├── auth.strategy.ts │ │ ├── dto │ │ │ └── auto.dto.ts │ │ ├── auth.controller.ts │ │ └── auth.service.ts │ └── user │ │ ├── user.service.spec.ts │ │ ├── user.controller.spec.ts │ │ ├── schema │ │ ├── user.schema.ts │ │ ├── friends.schema.ts │ │ └── friend-request.schema.ts │ │ ├── user.module.ts │ │ ├── dto │ │ ├── friend.ts │ │ ├── send-friend-request.dto.ts │ │ └── user.dto.ts │ │ ├── user.controller.ts │ │ └── user.service.ts ├── config │ ├── redis.config.ts │ ├── mongo.config.ts │ ├── email.config.ts │ └── minio.config.ts ├── main.ts └── app.module.ts ├── tsconfig.build.json ├── test ├── jest-e2e.json └── app.e2e-spec.ts ├── .prettierrc ├── .editorconfig ├── ecosystem.config.js ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── TASK.md │ ├── DISCUSS.yml │ ├── FEATURE.yml │ ├── Regression.yml │ └── BUGS.yml ├── workflows │ ├── lint.yml │ └── build.yml └── PULL_REQUEST_TEMPLATE.md ├── .vscode ├── launch.json └── settings.json ├── global.d.ts ├── nest-cli.json ├── README.md ├── .gitignore ├── docker-compose.yml ├── .env.development ├── Dockerfile ├── tsconfig.json ├── LICENSE ├── CONTRIBUTING-zh.md ├── eslint.config.cjs ├── CONTRIBUTING.md ├── mongo-init.js ├── package.json └── commitlint.config.cjs /prometheus.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "sourceMaps": true 3 | } 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | package-manager=pnpm@9.4.0 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | .next/ 4 | pnpm-lock.yaml 5 | 6 | src/metadata.ts -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | /src/config*.yaml 4 | /src/deploy*.md 5 | /.git 6 | /.vscode 7 | /.husky 8 | .DS_Store 9 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | export function getCurrentTimestamp(): number { 2 | return Date.parse(new Date().toString()) / 1000; 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './data-type'; 2 | export * from './date'; 3 | export * from './file'; 4 | export * from './constant'; 5 | export * from './utils'; 6 | -------------------------------------------------------------------------------- /src/common/enum/notification-type.enum.ts: -------------------------------------------------------------------------------- 1 | export enum NotificationType { 2 | FRIEND_REQUEST = 'friendRequest', 3 | FRIEND_REQUEST_UPDATED = 'friendRequestUpdated', 4 | PRIVATE_MESSAGE = 'privateMessage', 5 | // 其他通知类型可以在这里添加 6 | } 7 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/core/exceptions/login.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | export class LoginException extends HttpException { 4 | constructor(message: string) { 5 | super(message, HttpStatus.UNAUTHORIZED); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/constant.ts: -------------------------------------------------------------------------------- 1 | export const TIMEOUT_INTERCEPTOR = 10000; 2 | 3 | export const jwtConstants = { 4 | secret: 5 | 'Developed a front-end scaffold based on PNPM and Turborepo, aimed at quickly creating various types of projects for users.', 6 | }; 7 | -------------------------------------------------------------------------------- /src/common/logs/logs.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { LoggerService } from './logs.service'; 4 | 5 | @Module({ 6 | providers: [LoggerService], 7 | exports: [LoggerService], 8 | }) 9 | export class LogsModule {} 10 | -------------------------------------------------------------------------------- /src/core/exceptions/validation.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | export class ValidationException extends HttpException { 4 | constructor(message: string) { 5 | super(message, HttpStatus.BAD_REQUEST); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 2, 4 | "arrowParens": "always", 5 | "bracketSpacing": true, 6 | "proseWrap": "preserve", 7 | "trailingComma": "all", 8 | "jsxSingleQuote": false, 9 | "printWidth": 100, 10 | "endOfLine": "auto" 11 | } 12 | -------------------------------------------------------------------------------- /src/core/decorate/user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const User = createParamDecorator((data: unknown, ctx: ExecutionContext) => { 4 | const request = ctx.switchToHttp().getRequest(); 5 | 6 | return request.user; 7 | }); 8 | -------------------------------------------------------------------------------- /src/common/enum/socket.ts: -------------------------------------------------------------------------------- 1 | export enum SocketKeys { 2 | SINGLE_CHAT = 'SINGLE_CHAT', // 单聊 3 | GROUP_CHAT = 'GROUP_CHAT', // 群聊 4 | FRIEND_REQUEST_CREATED = 'FRIEND_REQUEST_CREATED', // 申请好友 5 | FRIEND_REQUEST_UPDATED = 'FRIEND_REQUEST_UPDATED', // 同意申请好友 6 | HEARTBEAT = 'HEARTBEAT', // 心跳检测 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab -------------------------------------------------------------------------------- /src/common/email/email.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { LoggerService } from '../logs/logs.service'; 4 | import { EmailService } from './email.service'; 5 | 6 | @Module({ 7 | providers: [EmailService, LoggerService], 8 | exports: [EmailService], 9 | }) 10 | export class EmailModule {} 11 | -------------------------------------------------------------------------------- /src/common/tasks/tasks.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ScheduleModule } from '@nestjs/schedule'; 3 | 4 | import { TasksService } from './tasks.service'; 5 | 6 | @Module({ 7 | imports: [ScheduleModule.forRoot()], 8 | providers: [TasksService], 9 | exports: [TasksService], 10 | }) 11 | export class TasksModule {} 12 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'online-editor-server', // 应用的名称 5 | script: 'dist/main.js', // 编译后的入口文件路径 6 | 7 | // 默认环境(开发环境) 8 | env: { 9 | NODE_ENV: 'development', 10 | }, 11 | 12 | // 生产环境 13 | env_production: { 14 | NODE_ENV: 'production', 15 | }, 16 | }, 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | ## To encourage contributors to use issue templates, we don't allow blank issues 2 | blank_issues_enabled: false 3 | 4 | contact_links: 5 | - name: "\u2753 Discord Community of Create-Neat" 6 | url: 'https://raw.githubusercontent.com/xun082/md/main/blogs.images20240307173700.png' 7 | about: 'Please ask support questions or discuss suggestions/enhancements here.' 8 | -------------------------------------------------------------------------------- /src/api/code-questions/code-questions.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post } from '@nestjs/common'; 2 | 3 | import { CodeQuestionsService } from './code-questions.service'; 4 | 5 | @Controller('/questions') 6 | export class CodeQuestionsController { 7 | constructor(private readonly codeQuestionsService: CodeQuestionsService) {} 8 | 9 | @Post('/list') 10 | getQuestions() { 11 | return this.codeQuestionsService.getQuestions(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/config/redis.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | 3 | import { RedisConfigEnum } from '../common/enum/config.enum'; 4 | 5 | export default (configService: ConfigService) => ({ 6 | port: parseInt(configService.get(RedisConfigEnum.REDIS_PORT)), 7 | host: configService.get(RedisConfigEnum.REDIS_HOST), 8 | password: configService.get(RedisConfigEnum.REDIS_PASSWORD), 9 | db: configService.get(RedisConfigEnum.REDIS_DB), 10 | }); 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Launch via PNPM", 6 | "request": "launch", 7 | "runtimeArgs": ["run-script", "start:debug"], 8 | "runtimeExecutable": "pnpm", 9 | "runtimeVersion": "18.18.0", 10 | "internalConsoleOptions": "neverOpen", 11 | "console": "integratedTerminal", 12 | "skipFiles": ["/**"], 13 | "type": "node" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/api/collaborate-doc/dto/collaborate-doc.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class DetailDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | recordId: string; 7 | } 8 | 9 | export class CreateShareLinkDto { 10 | @IsString() 11 | recordId: string; 12 | 13 | @IsString() 14 | @IsOptional() 15 | accessLevel?: string; 16 | } 17 | 18 | export class ShareDetailDto { 19 | @IsString() 20 | shareId: string; 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export function generateDefaultPassword(): string { 2 | const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 3 | let password = ''; 4 | 5 | for (let i = 0; i < 10; i++) { 6 | password += chars.charAt(Math.floor(Math.random() * chars.length)); 7 | } 8 | 9 | return password; 10 | } 11 | 12 | export function generateVerificationCode(): string { 13 | return String(Math.floor(100000 + Math.random() * 900000)); 14 | } 15 | -------------------------------------------------------------------------------- /src/api/cache-store/cache-store.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common'; 2 | 3 | import { CacheStoreService } from './cache-store.service'; 4 | import { CollaborateDocModule } from '../collaborate-doc/collaborate-doc.module'; 5 | 6 | import { RedisModule } from '@/common/redis/redis.module'; 7 | 8 | @Module({ 9 | imports: [RedisModule, forwardRef(() => CollaborateDocModule)], 10 | exports: [CacheStoreService], 11 | providers: [CacheStoreService], 12 | }) 13 | export class CacheStoreModule {} 14 | -------------------------------------------------------------------------------- /src/api/code-questions/schema/code-questions.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { Document, HydratedDocument } from 'mongoose'; 3 | 4 | @Schema() 5 | export class CodeQuestion extends Document { 6 | @Prop({ required: true }) 7 | docName: string; 8 | 9 | @Prop({ required: true }) 10 | desc: string; 11 | } 12 | 13 | export type CodeQuestionDocument = HydratedDocument; 14 | 15 | export const CodeQuestionSchema = SchemaFactory.createForClass(CodeQuestion); 16 | -------------------------------------------------------------------------------- /src/config/mongo.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { MongooseModuleOptions } from '@nestjs/mongoose'; 3 | 4 | import { MongoDbConfigEnum, MongoDbUrlEnum } from '../common/enum/config.enum'; 5 | 6 | export default (configService: ConfigService): MongooseModuleOptions => { 7 | const dbName = configService.get(MongoDbConfigEnum.MONGODB_DATABASE); 8 | 9 | return { 10 | uri: configService.get(MongoDbUrlEnum.MONGODB_URL), 11 | retryAttempts: 2, 12 | dbName, 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'y-websocket/bin/utils' { 2 | import { WebSocket } from 'ws'; 3 | import { IncomingMessage } from 'http'; 4 | export function setupWSConnection( 5 | ws: WebSocket, 6 | request: IncomingMessage, 7 | options?: { docName?: string; gc?: boolean }, 8 | ): void; 9 | export function setPersistence({ 10 | bindState, 11 | writeState, 12 | }: { 13 | bindState: (docName: string, ydoc: Y.Doc) => Promise; 14 | writeState: (docName: string, ydoc: Y.Doc) => Promise; 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/core/events/friend-request.events.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | 3 | export class FriendRequestEvent { 4 | public readonly senderId: Types.ObjectId; 5 | public readonly receiverId: Types.ObjectId; 6 | public readonly description?: string; 7 | 8 | constructor(data: { 9 | senderId: Types.ObjectId; 10 | receiverId: Types.ObjectId; 11 | description?: string; 12 | }) { 13 | this.senderId = data.senderId; 14 | this.receiverId = data.receiverId; 15 | this.description = data.description; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/common/tasks/tasks.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Cron } from '@nestjs/schedule'; 3 | 4 | @Injectable() 5 | export class TasksService { 6 | constructor() {} 7 | 8 | // 任务每15分钟执行一次 9 | @Cron('0 0/5 * * * *', { 10 | name: 'calc data visualization', 11 | }) 12 | async calcDv() { 13 | console.log(1); 14 | } 15 | 16 | // @Cron('* * * * * *', { 17 | // name: 'calc data visualization' 18 | // }) 19 | // async test() { 20 | // console.log('任务每秒执行一次'); 21 | // } 22 | } 23 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true, 7 | "typeCheck": true, 8 | "builder": "swc", 9 | "plugins": [ 10 | { 11 | "name": "@nestjs/swagger", 12 | "options": { 13 | "introspectComments": true, 14 | "controllerKeyOfComment": "summary", 15 | "dtoFileNameSuffix": [".dto.ts", ".schema.ts"] 16 | } 17 | } 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/api/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { AuthService } from './auth.service'; 4 | 5 | describe('AuthService', () => { 6 | let service: AuthService; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [AuthService], 11 | }).compile(); 12 | 13 | service = module.get(AuthService); 14 | }); 15 | 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { UserService } from './user.service'; 4 | 5 | describe('UserService', () => { 6 | let service: UserService; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [UserService], 11 | }).compile(); 12 | 13 | service = module.get(UserService); 14 | }); 15 | 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [前端地址](https://github.com/xun082/online-edit-web) 2 | 3 | 本项目使用 Docker 进行容器化管理,确保你能够轻松启动并运行项目。无需额外配置,只需确保本地安装了 Docker,即可开始运行项目。 4 | 5 | 在启动项目之前,请确保你已在本地安装并运行以下工具: 6 | 7 | - [Docker](https://www.docker.com/)(确保 Docker 已安装并运行) 8 | 9 | 首先,克隆项目的源代码到本地: 10 | 11 | ```bash 12 | git clone https://github.com/xun082/online-edit-server.git 13 | ``` 14 | 15 | ```bash 16 | cd online-edit-server 17 | ``` 18 | 19 | 通过 Docker Compose 一条命令即可启动项目: 20 | 21 | ```bash 22 | docker-compose up -d 23 | ``` 24 | 25 | 等待启动完成之后即可启动NestJs项目: 26 | 27 | ```bash 28 | pnpm start:dev 29 | ``` 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | /logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | /src/metadata.ts 38 | -------------------------------------------------------------------------------- /src/config/email.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | 3 | import { EmailConfigEnum } from '../common/enum/config.enum'; 4 | 5 | export default (configService: ConfigService) => { 6 | const host = configService.get(EmailConfigEnum.EMAIL_HOST); 7 | const port = configService.get(EmailConfigEnum.EMAIL_PORT); 8 | const authUser = configService.get(EmailConfigEnum.EMAIL_AUTH_USER); 9 | const authPass = configService.get(EmailConfigEnum.EMAIL_AUTH_PASS); 10 | 11 | return { 12 | host, 13 | port, 14 | authUser, 15 | authPass, 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /src/api/code-questions/code-questions.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit } from '@nestjs/common'; 2 | import { Model } from 'mongoose'; 3 | import { InjectModel } from '@nestjs/mongoose'; 4 | 5 | import { CodeQuestion } from './schema/code-questions.schema'; 6 | 7 | @Injectable() 8 | export class CodeQuestionsService implements OnModuleInit { 9 | constructor(@InjectModel(CodeQuestion.name) private codeQuestModel: Model) {} 10 | onModuleInit() {} 11 | 12 | async getQuestions(): Promise> { 13 | const list = await this.codeQuestModel.find({}).exec(); 14 | 15 | return list; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/TASK.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🛎 TODO LIST 3 | about: Use this template to post new development tasks 4 | title: '[Task] Task Title' 5 | labels: 'To Be Claimed' 6 | assignees: '' 7 | --- 8 | 9 | ### Task Description 10 | 11 | Briefly describe the goal that this task aims to achieve. 12 | 13 | ### Specific Task Requirements 14 | 15 | - [ ] Requirement 1 16 | - [ ] Requirement 2 17 | - [ ] Requirement 3 18 | 19 | ### Claiming Process 20 | 21 | If you would like to claim this task, please reply with “I want to claim this task” in the comments. 22 | 23 | ### Task Remarks 24 | 25 | Any additional information or remarks about the task. 26 | -------------------------------------------------------------------------------- /src/api/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { AuthController } from './auth.controller'; 4 | import { AuthService } from './auth.service'; 5 | 6 | describe('AuthController', () => { 7 | let controller: AuthController; 8 | 9 | beforeEach(async () => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | controllers: [AuthController], 12 | providers: [AuthService], 13 | }).compile(); 14 | 15 | controller = module.get(AuthController); 16 | }); 17 | 18 | it('should be defined', () => { 19 | expect(controller).toBeDefined(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/api/user/user.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { UserController } from './user.controller'; 4 | import { UserService } from './user.service'; 5 | 6 | describe('UserController', () => { 7 | let controller: UserController; 8 | 9 | beforeEach(async () => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | controllers: [UserController], 12 | providers: [UserService], 13 | }).compile(); 14 | 15 | controller = module.get(UserController); 16 | }); 17 | 18 | it('should be defined', () => { 19 | expect(controller).toBeDefined(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/common/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Request } from '@nestjs/common'; 2 | 3 | export interface ResponseModel { 4 | code: number; 5 | message: string; 6 | data: T; 7 | } 8 | 9 | export interface SmsCodeType { 10 | RequestId: string; 11 | Code: string; 12 | BizId: string; 13 | } 14 | 15 | export interface JwtPayload { 16 | _id: string; 17 | username: string; 18 | email: string; 19 | } 20 | 21 | export interface RequestWithUser extends Request { 22 | user: JwtPayload; 23 | } 24 | 25 | export type ObjectType = Record; 26 | 27 | export enum FriendRequestStatus { 28 | PENDING = 'pending', 29 | ACCEPTED = 'accepted', 30 | REJECTED = 'rejected', 31 | } 32 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | 5 | import { AppModule } from './../src/app.module'; 6 | 7 | describe('AppController (e2e)', () => { 8 | let app: INestApplication; 9 | 10 | beforeEach(async () => { 11 | const moduleFixture: TestingModule = await Test.createTestingModule({ 12 | imports: [AppModule], 13 | }).compile(); 14 | 15 | app = moduleFixture.createNestApplication(); 16 | await app.init(); 17 | }); 18 | 19 | it('/ (GET)', () => { 20 | return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/api/code-questions/code-questions.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | 4 | import { CodeQuestionsService } from './code-questions.service'; 5 | import { CodeQuestionsController } from './code-questions.controller'; 6 | import { CodeQuestion, CodeQuestionSchema } from './schema/code-questions.schema'; 7 | 8 | @Module({ 9 | imports: [ 10 | MongooseModule.forFeature([ 11 | { name: CodeQuestion.name, schema: CodeQuestionSchema, collection: 'code_questions' }, 12 | ]), 13 | ], 14 | exports: [CodeQuestionsService], 15 | providers: [CodeQuestionsService], 16 | controllers: [CodeQuestionsController], 17 | }) 18 | export class CodeQuestionsModule {} 19 | -------------------------------------------------------------------------------- /src/common/redis/redis.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import Redis from 'ioredis'; 3 | import { ConfigService } from '@nestjs/config'; 4 | 5 | import loadRedisConfig from '../../config/redis.config'; 6 | import { RedisService } from './redis.service'; 7 | 8 | @Global() 9 | @Module({ 10 | providers: [ 11 | { 12 | provide: 'REDIS_CLIENT', 13 | async useFactory(configService: ConfigService) { 14 | const redisInstance = new Redis({ 15 | ...loadRedisConfig(configService), 16 | }); 17 | 18 | return redisInstance; 19 | }, 20 | inject: [ConfigService], 21 | }, 22 | RedisService, 23 | ], 24 | exports: [RedisService], 25 | }) 26 | export class RedisModule {} 27 | -------------------------------------------------------------------------------- /src/api/collaborate-doc/schema/collaborate-doc.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { Document, HydratedDocument } from 'mongoose'; 3 | 4 | @Schema() 5 | export class CollaborateDoc extends Document { 6 | @Prop({ required: true }) 7 | docName: string; 8 | 9 | @Prop({ type: Buffer }) 10 | state: Buffer; 11 | 12 | @Prop({ default: Date.now }) 13 | lastModified: Date; 14 | 15 | @Prop() 16 | shareId: string; 17 | 18 | @Prop() 19 | shareLink: string; 20 | 21 | @Prop({ default: 'edit' }) 22 | accessLevel: string; 23 | } 24 | 25 | export type CollaborateDocDocument = HydratedDocument; 26 | 27 | export const CollaborateDocSchema = SchemaFactory.createForClass(CollaborateDoc); 28 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Commit Message Check on PR 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [20] 13 | steps: 14 | - name: Checkout 🛎️ 15 | uses: actions/checkout@v3 16 | with: 17 | persist-credentials: false 18 | 19 | - name: Install PNPM 20 | uses: pnpm/action-setup@v2 21 | with: 22 | version: 9.4.0 23 | 24 | - name: Install Deps 25 | run: pnpm i --no-frozen-lockfile 26 | 27 | - name: Format 28 | run: | 29 | pnpm run format:ci 30 | 31 | - name: Lint 32 | run: pnpm run lint:ci 33 | -------------------------------------------------------------------------------- /src/config/minio.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | 3 | import { MiNiOConfigEnum } from '@/common/enum/config.enum'; 4 | 5 | interface MiNiOConfig { 6 | endPoint: string; 7 | port: number; 8 | useSSL: boolean; 9 | accessKey: string; 10 | secretKey: string; 11 | } 12 | 13 | export default function loadMiNiOConfig(configService: ConfigService): MiNiOConfig { 14 | const { MINIO_ACCESS_KEY, MINIO_ENDPOINT, MINIO_PORT, MINIO_SECRET_KEY } = MiNiOConfigEnum; 15 | 16 | return { 17 | endPoint: configService.get(MINIO_ENDPOINT), 18 | port: parseInt(configService.get(MINIO_PORT), 10), 19 | useSSL: false, 20 | accessKey: configService.get(MINIO_ACCESS_KEY), 21 | secretKey: configService.get(MINIO_SECRET_KEY), 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/DISCUSS.yml: -------------------------------------------------------------------------------- 1 | name: '💬 Discussion' 2 | description: Start a discussion 3 | title: '[Discuss]: Discussion Title' 4 | labels: [discussion] 5 | assignees: [] 6 | body: 7 | - type: textarea 8 | id: topic 9 | attributes: 10 | label: Discussion Topic 11 | description: 'Please provide a clear and concise topic for discussion.' 12 | validations: 13 | required: true 14 | - type: textarea 15 | id: screenshots 16 | attributes: 17 | label: Screenshots 18 | description: 'If applicable, add screenshots to help explain your topic.' 19 | - type: textarea 20 | id: links 21 | attributes: 22 | label: Links 23 | description: 'Include any relevant links that could provide more context for the discussion.' 24 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | mongo: 5 | image: mongo 6 | container_name: mongodb 7 | command: mongod --auth 8 | ports: 9 | - '27017:27017' 10 | environment: 11 | MONGO_INITDB_ROOT_USERNAME: admin 12 | MONGO_INITDB_ROOT_PASSWORD: online 13 | volumes: 14 | - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro 15 | - mongodata:/data/db 16 | restart: 'always' 17 | 18 | redis: 19 | image: redis 20 | ports: 21 | - '6379:6379' 22 | environment: 23 | - REDIS_PASSWORD=moment 24 | command: redis-server --requirepass moment 25 | volumes: 26 | - redis-data:/data 27 | restart: always 28 | 29 | volumes: 30 | mongodata: 31 | config: 32 | redis-data: 33 | data: 34 | -------------------------------------------------------------------------------- /src/common/fastify/index.ts: -------------------------------------------------------------------------------- 1 | import { NestFastifyApplication } from '@nestjs/platform-fastify'; 2 | import { 3 | FastifyInstance, 4 | FastifyPluginAsync, 5 | FastifyPluginCallback, 6 | FastifyPluginOptions, 7 | FastifyRegisterOptions, 8 | } from 'fastify'; 9 | 10 | export async function registerFastifyPlugin( 11 | app: NestFastifyApplication, 12 | plugin: 13 | | FastifyPluginCallback 14 | | FastifyPluginAsync 15 | | Promise<{ 16 | default: FastifyPluginCallback; 17 | }> 18 | | Promise<{ 19 | default: FastifyPluginAsync; 20 | }>, 21 | opts?: FastifyRegisterOptions, 22 | ): Promise { 23 | return (await app.register(plugin as never, opts as never)) as unknown as FastifyInstance; 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/data-type.ts: -------------------------------------------------------------------------------- 1 | const toString = Object.prototype.toString; 2 | 3 | /** 4 | * Generic function for type checking. 5 | */ 6 | function isOfType(obj: T, type: string): boolean { 7 | return toString.call(obj) === `[object ${type}]`; 8 | } 9 | 10 | export function isObject(obj: T) { 11 | return isOfType(obj, 'Object'); 12 | } 13 | 14 | export function isRegExp(obj: T) { 15 | return isOfType(obj, 'RegExp'); 16 | } 17 | 18 | export function isString(obj: T) { 19 | return isOfType(obj, 'String'); 20 | } 21 | 22 | export function isValidArrayIndex(val: number): boolean { 23 | const n = parseFloat(String(val)); 24 | 25 | return n >= 0 && Math.floor(n) === n && isFinite(val); 26 | } 27 | 28 | // 判断是否为uuid 29 | export function isUUID(str: string): boolean { 30 | return /\w{8}(-\w{4}){3}-\w{12}/.test(str); 31 | } 32 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll": "explicit", 6 | "source.fixAll.eslint": "explicit" 7 | }, 8 | "eslint.validate": [ 9 | "javascript", 10 | "javascriptreact", 11 | "typescript", 12 | "typescriptreact", 13 | "json", 14 | "jsonc", 15 | "markdown", 16 | "html" 17 | ], 18 | "prettier.requireConfig": true, 19 | "[javascript]": { 20 | "editor.defaultFormatter": "esbenp.prettier-vscode" 21 | }, 22 | "[javascriptreact]": { 23 | "editor.defaultFormatter": "esbenp.prettier-vscode" 24 | }, 25 | "[typescript]": { 26 | "editor.defaultFormatter": "esbenp.prettier-vscode" 27 | }, 28 | "[typescriptreact]": { 29 | "editor.defaultFormatter": "esbenp.prettier-vscode" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/api/collaborate-doc/collaborate-doc.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | 4 | import { CollaborateDocService } from './collaborate-doc.service'; 5 | import { CollaborateDocGateway } from './collaborate-doc.gateway'; 6 | import { CollaborateDocController } from './collaborate-doc.controller'; 7 | import { CollaborateDoc, CollaborateDocSchema } from './schema/collaborate-doc.schema'; 8 | import { CacheStoreModule } from '../cache-store/cache-store.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | MongooseModule.forFeature([{ name: CollaborateDoc.name, schema: CollaborateDocSchema }]), 13 | forwardRef(() => CacheStoreModule), 14 | ], 15 | providers: [CollaborateDocService, CollaborateDocGateway], 16 | exports: [CollaborateDocService], 17 | controllers: [CollaborateDocController], 18 | }) 19 | export class CollaborateDocModule {} 20 | -------------------------------------------------------------------------------- /src/core/decorate/minio-url.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registerDecorator, 3 | ValidationOptions, 4 | ValidatorConstraint, 5 | ValidatorConstraintInterface, 6 | } from 'class-validator'; 7 | 8 | @ValidatorConstraint({ async: false }) 9 | export class IsMinioUrlConstraint implements ValidatorConstraintInterface { 10 | validate(url: string) { 11 | // 使用正则表达式来验证URL是否合法,允许更复杂的查询字符串 12 | const urlRegex = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i; 13 | 14 | return urlRegex.test(url); 15 | } 16 | 17 | defaultMessage() { 18 | return 'URL格式无效'; 19 | } 20 | } 21 | 22 | export function IsMinioUrl(validationOptions?: ValidationOptions) { 23 | return function (object: Record, propertyName: string) { 24 | registerDecorator({ 25 | target: object.constructor, 26 | propertyName: propertyName, 27 | options: validationOptions, 28 | constraints: [], 29 | validator: IsMinioUrlConstraint, 30 | }); 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | MONGODB_HOST = localhost 2 | MONGODB_PORT = 27017 3 | MONGODB_USERNAME = admin 4 | MONGODB_PASSWORD = online 5 | MONGODB_DATABASE = online 6 | MONGODB_AUTH_SOURCE = admin 7 | 8 | MONGODB_URL = mongodb://admin:online@localhost:27017/online?authSource=admin 9 | 10 | EMAIL_HOST = smtp.qq.com 11 | EMAIL_PORT = 587 12 | EMAIL_AUTH_USER = 2042204285@qq.com 13 | EMAIL_AUTH_PASS = qcvevlsjofkycgfi 14 | 15 | MINIO_ENDPOINT = localhost 16 | MINIO_PORT = 9000 17 | MINIO_ACCESS_KEY = moment 18 | MINIO_SECRET_KEY = moment666 19 | MINIO_BUCKET = moment 20 | 21 | REDIS_HOST = localhost 22 | REDIS_PORT = 6379 23 | REDIS_PASSWORD = moment 24 | REDIS_DB = 0 25 | 26 | GITHUB_CLIENT_ID = ad52458044df085099a1 27 | GITHUB_CLIENT_SECRET = 91b51ab4362c402cffce25c08e9571b57ad0fc51 28 | GITHUB_CLIENT_URL = https://github.com/login/oauth/access_token 29 | GITHUB_USER_INFO_URL = https://api.github.com/user 30 | GITHUB_CALLBACK_URL = http://localhost:8080/api/v1/auth/github/callback 31 | 32 | -------------------------------------------------------------------------------- /src/api/user/schema/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { HydratedDocument } from 'mongoose'; 3 | 4 | import { getCurrentTimestamp } from '@/utils'; 5 | 6 | @Schema() 7 | export class User { 8 | @Prop() 9 | email: string; 10 | 11 | @Prop() 12 | username: string; 13 | 14 | @Prop({ default: '123456789' }) 15 | password: string; 16 | 17 | @Prop({ default: 'https://cdn.pixabay.com/photo/2024/07/17/10/25/ocean-8901157_960_720.jpg' }) 18 | avatar: string; 19 | 20 | @Prop({ default: getCurrentTimestamp }) 21 | createdAt: number; 22 | 23 | @Prop({ default: '未知地区' }) 24 | region: string; 25 | 26 | @Prop({ default: '暂无签名' }) 27 | signature: string; 28 | 29 | @Prop({ default: 'https://cdn.pixabay.com/photo/2024/07/17/10/25/ocean-8901157_960_720.jpg' }) 30 | backgroundImage: string; 31 | } 32 | 33 | export const UserSchema = SchemaFactory.createForClass(User); 34 | export type UserDocument = HydratedDocument; 35 | -------------------------------------------------------------------------------- /src/core/filter/WebsocketExceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, HttpException } from '@nestjs/common'; 2 | import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets'; 3 | import { Socket } from 'socket.io'; 4 | 5 | @Catch(WsException, HttpException) 6 | export class WebsocketExceptionsFilter extends BaseWsExceptionFilter { 7 | catch(exception: WsException | HttpException, host: ArgumentsHost) { 8 | const client = host.switchToWs().getClient() as Socket; 9 | const data = host.switchToWs().getData(); 10 | const error = exception instanceof WsException ? exception.getError() : exception.getResponse(); 11 | const details = error instanceof Object ? { ...error } : { message: error }; 12 | 13 | client.emit( 14 | 'error', 15 | JSON.stringify({ 16 | event: 'error', 17 | data: { 18 | id: (client as any).id, 19 | rid: data.rid, 20 | ...details, 21 | }, 22 | }), 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine as base 2 | 3 | RUN npm config set registry https://registry.npmmirror.com/ 4 | 5 | 6 | ENV PNPM_REGISTRY=https://registry.npmmirror.com/ 7 | RUN npm i -g pnpm 8 | 9 | FROM base As dependencies 10 | 11 | WORKDIR /home/infinity/infinity-server 12 | COPY package.json pnpm-lock.yaml ./ 13 | RUN pnpm install 14 | 15 | FROM base AS build 16 | 17 | WORKDIR /home/infinity/infinity-server 18 | COPY . . 19 | COPY --from=dependencies /home/infinity/infinity-server/node_modules ./node_modules 20 | RUN pnpm build 21 | RUN pnpm prune --prod 22 | 23 | FROM base AS deploy 24 | 25 | WORKDIR /home/infinity/infinity-server 26 | COPY --from=build /home/infinity/infinity-server/dist/ ./dist/ 27 | COPY --from=build /home/infinity/infinity-server/node_modules ./node_modules 28 | 29 | ENV DATABASE_HOST=mongo 30 | ENV DATABASE_PORT=27017 31 | ENV DATABASE_NAME=interview 32 | ENV DATABASE_USER=admin 33 | ENV DATABASE_PASS=interview666 34 | 35 | EXPOSE 8000 36 | 37 | CMD ["node", "dist/main.js"] 38 | -------------------------------------------------------------------------------- /src/api/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | 4 | import { UserService } from './user.service'; 5 | import { UserController } from './user.controller'; 6 | import { User, UserSchema } from './schema/user.schema'; 7 | import { FriendRequest, FriendRequestSchema } from './schema/friend-request.schema'; 8 | import { Friends, FriendsSchema } from './schema/friends.schema'; 9 | 10 | import { RedisModule } from '@/common/redis/redis.module'; 11 | 12 | @Module({ 13 | controllers: [UserController], 14 | providers: [UserService], 15 | imports: [ 16 | MongooseModule.forFeature([ 17 | { name: User.name, schema: UserSchema, collection: 'user' }, 18 | { name: FriendRequest.name, schema: FriendRequestSchema, collection: 'friend_request' }, 19 | { name: Friends.name, schema: FriendsSchema, collection: 'friends' }, 20 | ]), 21 | 22 | RedisModule, 23 | ], 24 | exports: [UserService], 25 | }) 26 | export class UserModule {} 27 | -------------------------------------------------------------------------------- /src/api/user/schema/friends.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { HydratedDocument, Types, Schema as MongooseSchema } from 'mongoose'; 3 | 4 | import { getCurrentTimestamp } from '@/utils'; 5 | 6 | @Schema() 7 | export class Friends { 8 | @Prop({ type: MongooseSchema.Types.ObjectId, required: true, ref: 'User' }) 9 | user_id: Types.ObjectId; // 当前用户的ID 10 | 11 | @Prop({ type: MongooseSchema.Types.ObjectId, required: true, ref: 'User' }) 12 | friend_id: Types.ObjectId; // 好友的用户ID 13 | 14 | @Prop({ default: getCurrentTimestamp }) 15 | createdAt: number; 16 | 17 | @Prop({ default: '' }) 18 | userRemark: string; // userId 对 friendId 的备注名 19 | 20 | @Prop({ default: '' }) 21 | friendRemark: string; // friendId 对 userId 的备注名 22 | } 23 | 24 | export const FriendsSchema = SchemaFactory.createForClass(Friends); 25 | export type FriendsDocument = HydratedDocument; 26 | 27 | // 添加唯一性索引,确保同一对用户只能有一条好友关系 28 | FriendsSchema.index({ userId: 1, friendId: 1 }, { unique: true }); 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2022", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "paths": { 14 | "@/*": ["./src/*"], 15 | "@prisma/client": ["./prisma/client"], 16 | "*": ["*", "src/*"] 17 | }, 18 | "incremental": true, 19 | "skipLibCheck": true, 20 | "strictNullChecks": false, 21 | "noImplicitAny": false, 22 | "strictBindCallApply": false, 23 | "forceConsistentCasingInFileNames": false, 24 | "noFallthroughCasesInSwitch": false 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | "**/*.ts", 29 | "**/*.tsx", 30 | ".next/types/**/*.ts", 31 | "eslint.config.cjs", 32 | ".storybook/**/*", 33 | "mongo-init.js", 34 | "ecosystem.config.js" 35 | ], 36 | "exclude": ["node_modules"] 37 | } 38 | -------------------------------------------------------------------------------- /src/core/decorate/api-response.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, Type } from '@nestjs/common'; 2 | import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger'; 3 | 4 | import { ResponseDto } from '@/common/dto/response.dto'; 5 | 6 | type ModelType = Type | [Type]; 7 | 8 | export function ApiResponseWithDto( 9 | model: ModelType, 10 | description: string = 'Operation successful', 11 | status: number = 200, 12 | ) { 13 | const isArray = Array.isArray(model); 14 | 15 | return applyDecorators( 16 | ApiExtraModels(ResponseDto, ...(isArray ? model : [model])), 17 | ApiResponse({ 18 | status, 19 | description, 20 | schema: { 21 | allOf: [ 22 | { $ref: getSchemaPath(ResponseDto) }, 23 | { 24 | properties: { 25 | data: isArray 26 | ? { type: 'array', items: { $ref: getSchemaPath((model as [Type])[0]) } } 27 | : { $ref: getSchemaPath(model as Type) }, 28 | }, 29 | }, 30 | ], 31 | }, 32 | }), 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 QuestMinds 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/core/interceptor/timeout.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | RequestTimeoutException, 7 | } from '@nestjs/common'; 8 | import { Observable, TimeoutError, catchError, throwError, timeout } from 'rxjs'; 9 | import { LoggerService } from 'src/common/logs/logs.service'; 10 | 11 | import { TIMEOUT_INTERCEPTOR } from '@/utils'; 12 | 13 | @Injectable() 14 | export class TimeoutInterceptor implements NestInterceptor { 15 | private readonly requestTimeout = TIMEOUT_INTERCEPTOR; 16 | constructor(private readonly logger: LoggerService) {} 17 | 18 | intercept(context: ExecutionContext, next: CallHandler): Observable { 19 | return next.handle().pipe( 20 | timeout(this.requestTimeout), 21 | catchError((err) => { 22 | if (err instanceof TimeoutError) { 23 | this.logger.error(`Request timed out: ${context.switchToHttp().getRequest().url}`); 24 | 25 | return throwError(() => new RequestTimeoutException('Request timeout')); 26 | } 27 | 28 | return throwError(() => err); 29 | }), 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/api/collaborate-doc/collaborate-doc.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Post } from '@nestjs/common'; 2 | 3 | import { CollaborateDocService } from './collaborate-doc.service'; 4 | import { DetailDto, CreateShareLinkDto, ShareDetailDto } from './dto/collaborate-doc.dto'; 5 | 6 | @Controller('document') 7 | export class CollaborateDocController { 8 | constructor(private readonly collaborateDocService: CollaborateDocService) {} 9 | 10 | @Post('/create') 11 | createDoc(@Body('docName') docName: string) { 12 | return this.collaborateDocService.createDoc(docName); 13 | } 14 | 15 | @Get('/list') 16 | getRecordList() { 17 | return this.collaborateDocService.getDocList(); 18 | } 19 | 20 | @Post('/detail') 21 | getRecord(@Body('recordId') detail: DetailDto) { 22 | const { recordId } = detail; 23 | 24 | return this.collaborateDocService.getDoc(recordId); 25 | } 26 | 27 | @Post('/share/create') 28 | shareDoc(@Body() CreateShareLinkDto: CreateShareLinkDto) { 29 | return this.collaborateDocService.createShareLink(CreateShareLinkDto); 30 | } 31 | 32 | @Post('/share/detail') 33 | shareDetail(@Body() ShareDetailDto: ShareDetailDto) {} 34 | } 35 | -------------------------------------------------------------------------------- /src/core/decorate/match.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registerDecorator, 3 | ValidationArguments, 4 | ValidationOptions, 5 | ValidatorConstraint, 6 | ValidatorConstraintInterface, 7 | } from 'class-validator'; 8 | 9 | @ValidatorConstraint({ async: false }) 10 | export class MatchConstraint implements ValidatorConstraintInterface { 11 | validate(value: any, args: ValidationArguments) { 12 | console.log(value, args); 13 | 14 | const [relatedPropertyName] = args.constraints; 15 | const relatedValue = (args.object as any)[relatedPropertyName]; 16 | 17 | return value === relatedValue; 18 | } 19 | 20 | defaultMessage(args: ValidationArguments) { 21 | const [relatedPropertyName] = args.constraints; 22 | 23 | return `${args.property} and ${relatedPropertyName} do not match`; 24 | } 25 | } 26 | 27 | export function Match(property: string, validationOptions?: ValidationOptions) { 28 | debugger; 29 | 30 | return (object: any, propertyName: string) => { 31 | registerDecorator({ 32 | target: object.constructor, 33 | propertyName: propertyName, 34 | options: validationOptions, 35 | constraints: [property], 36 | validator: MatchConstraint, 37 | }); 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/api/user/schema/friend-request.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { Document, Types } from 'mongoose'; 3 | 4 | import { getCurrentTimestamp } from '@/utils'; 5 | import { FriendRequestStatus } from '@/common/types'; 6 | 7 | @Schema() 8 | export class FriendRequest { 9 | @Prop({ type: Types.ObjectId, required: true }) 10 | senderId: Types.ObjectId; // 发起请求的用户ID 11 | 12 | @Prop({ type: Types.ObjectId, required: true }) 13 | receiverId: Types.ObjectId; // 接收请求的用户ID 14 | 15 | @Prop({ 16 | type: String, 17 | required: true, 18 | minlength: [10, 'Description must be at least 10 characters long'], 19 | maxlength: [500, 'Description must be at most 500 characters long'], 20 | }) 21 | description: string; 22 | 23 | @Prop({ type: Number, default: getCurrentTimestamp }) 24 | createdAt: number; 25 | 26 | @Prop({ type: String, enum: FriendRequestStatus, default: FriendRequestStatus.PENDING }) 27 | status: FriendRequestStatus; 28 | 29 | @Prop({ type: String, default: '' }) 30 | remark?: string; 31 | } 32 | 33 | export type FriendRequestDocument = FriendRequest & Document; 34 | export const FriendRequestSchema = SchemaFactory.createForClass(FriendRequest); 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F680 Feature Request" 2 | description: "I have a suggestion \U0001F63B!" 3 | labels: ['feature'] 4 | body: 5 | - type: textarea 6 | validations: 7 | required: true 8 | attributes: 9 | label: 'Is your feature request related to a problem? Please describe it' 10 | description: 'A clear and concise description of what the problem is' 11 | placeholder: | 12 | I have an issue when ... 13 | 14 | - type: textarea 15 | validations: 16 | required: true 17 | attributes: 18 | label: "Describe the solution you'd like" 19 | description: 'A clear and concise description of what you want to happen. Add any considered drawbacks' 20 | 21 | - type: textarea 22 | attributes: 23 | label: 'Teachability, documentation, adoption, migration strategy' 24 | description: 'If you can, explain how users will be able to use this and possibly write out a version the docs. Maybe a screenshot or design?' 25 | 26 | - type: textarea 27 | validations: 28 | required: true 29 | attributes: 30 | label: 'What is the motivation / use case for changing the behavior?' 31 | description: 'Describe the motivation or the concrete use case' 32 | -------------------------------------------------------------------------------- /src/api/user/dto/friend.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty, IsEmail, IsOptional, IsMongoId } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class FriendDetailsDto { 5 | @ApiProperty({ description: '好友关系的ID', type: String }) 6 | @IsNotEmpty() 7 | @IsMongoId() 8 | id: string; // 好友关系的ID 9 | 10 | @ApiProperty({ description: '好友的用户ID', type: String }) 11 | @IsNotEmpty() 12 | @IsMongoId() 13 | friendId: string; // 好友的用户ID 14 | 15 | @ApiProperty({ description: '好友的电子邮件地址', type: String }) 16 | @IsNotEmpty() 17 | @IsEmail() 18 | friendEmail: string; // 好友的电子邮件地址 19 | 20 | @ApiProperty({ description: '好友的用户名', type: String }) 21 | @IsNotEmpty() 22 | @IsString() 23 | friendUsername: string; // 好友的用户名 24 | 25 | @ApiProperty({ description: '好友备注,可选字段', required: false, type: String }) 26 | @IsOptional() 27 | @IsString() 28 | friendRemark?: string; // 好友备注,可选字段 29 | 30 | @ApiProperty({ description: '好友关系创建时间戳', type: Number }) 31 | @IsNotEmpty() 32 | @IsString() 33 | createdAt: number; // 好友关系创建时间戳 34 | 35 | @ApiProperty({ description: '好友的头像URL,可选字段', required: false, type: String }) 36 | @IsOptional() 37 | @IsString() 38 | avatar?: string; // 好友的头像URL,可选字段 39 | } 40 | -------------------------------------------------------------------------------- /src/api/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { HttpModule } from '@nestjs/axios'; 5 | 6 | import { User, UserSchema } from '../user/schema/user.schema'; 7 | import { AuthService } from './auth.service'; 8 | import { AuthController } from './auth.controller'; 9 | import { JwtStrategy } from './auth.strategy'; 10 | import { UserModule } from '../user/user.module'; 11 | 12 | import { jwtConstants } from '@/utils'; 13 | import { RedisModule } from '@/common/redis/redis.module'; 14 | import { EmailModule } from '@/common/email/email.module'; 15 | 16 | @Module({ 17 | controllers: [AuthController], 18 | providers: [AuthService, JwtStrategy], 19 | imports: [ 20 | MongooseModule.forFeature([{ name: User.name, schema: UserSchema, collection: 'user' }]), 21 | JwtModule.registerAsync({ 22 | useFactory: async () => ({ 23 | global: true, 24 | secret: jwtConstants.secret, 25 | signOptions: { 26 | expiresIn: '77d', 27 | }, 28 | }), 29 | }), 30 | RedisModule, 31 | EmailModule, 32 | HttpModule, 33 | UserModule, 34 | ], 35 | exports: [AuthService], 36 | }) 37 | export class AuthModule {} 38 | -------------------------------------------------------------------------------- /src/api/auth/auth.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from 'passport-jwt'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { InjectModel } from '@nestjs/mongoose'; 6 | import { Model, Types } from 'mongoose'; 7 | 8 | import { User, UserDocument } from '../user/schema/user.schema'; 9 | 10 | import { jwtConstants } from '@/utils'; 11 | import { JwtPayload } from '@/common/types'; 12 | 13 | @Injectable() 14 | export class JwtStrategy extends PassportStrategy(Strategy) { 15 | constructor( 16 | protected configService: ConfigService, 17 | @InjectModel(User.name) private userModel: Model, 18 | ) { 19 | super({ 20 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 21 | ignoreExpiration: false, 22 | secretOrKey: jwtConstants.secret, 23 | }); 24 | } 25 | 26 | async validate(payload: any): Promise { 27 | const user = await this.userModel.findOne({ _id: payload.sub }).lean().exec(); 28 | 29 | if (!user) { 30 | throw new HttpException('未登录或该用户不存在!请前往登录/注册~', HttpStatus.UNAUTHORIZED); 31 | } 32 | 33 | return { 34 | _id: new Types.ObjectId(user._id).toHexString(), 35 | username: user.username, 36 | email: user.email, 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR Type 2 | 3 | What kind of change does this PR introduce? 4 | 5 | 6 | 7 | - [ ] Bugfix 8 | - [ ] Feature 9 | - [ ] Code style update (formatting, local variables) 10 | - [ ] Refactoring (no functional changes, no api changes) 11 | - [ ] Build related changes 12 | - [ ] CI related changes 13 | - [ ] Documentation update 14 | - [ ] Performance improvement 15 | - [ ] Test related changes 16 | - [ ] Security fix 17 | - [ ] Dependency update 18 | - [ ] Revert changes 19 | - [ ] Other... Please describe: 20 | 21 | ## What is the current behavior? 22 | 23 | - Closes # 24 | - Related to # 25 | 26 | ## What is the new behavior? 27 | 28 | 29 | 30 | ## Does this PR introduce a breaking change? 31 | 32 | - [ ] Yes 33 | - [ ] No 34 | 35 | 36 | 37 | ## UI Changes 38 | 39 | 40 | 41 | 42 | ## Other information 43 | -------------------------------------------------------------------------------- /src/common/enum/config.enum.ts: -------------------------------------------------------------------------------- 1 | export enum RedisConfigEnum { 2 | REDIS_HOST = 'REDIS_HOST', 3 | REDIS_PORT = 'REDIS_PORT', 4 | REDIS_PASSWORD = 'REDIS_PASSWORD', 5 | REDIS_DB = 'REDIS_DB', 6 | } 7 | 8 | export enum MongoDbConfigEnum { 9 | MONGODB_HOST = 'MONGODB_HOST', 10 | MONGODB_PORT = 'MONGODB_PORT', 11 | MONGODB_USERNAME = 'MONGODB_USERNAME', 12 | MONGODB_PASSWORD = 'MONGODB_PASSWORD', 13 | MONGODB_DATABASE = 'MONGODB_DATABASE', 14 | MONGODB_AUTH_SOURCE = 'MONGODB_AUTH_SOURCE', 15 | } 16 | 17 | export enum MongoDbUrlEnum { 18 | MONGODB_URL = 'MONGODB_URL', 19 | } 20 | 21 | export enum EmailConfigEnum { 22 | EMAIL_HOST = 'EMAIL_HOST', 23 | EMAIL_PORT = 'EMAIL_PORT', 24 | EMAIL_AUTH_USER = 'EMAIL_AUTH_USER', 25 | EMAIL_AUTH_PASS = 'EMAIL_AUTH_PASS', 26 | } 27 | 28 | export enum OSSConfigEnum { 29 | OSS_ACCESS_KEY_ID = 'OSS_ACCESS_KEY_ID', 30 | OSS_ACCESS_KEY_SECRET = 'OSS_ACCESS_KEY_SECRET', 31 | OSS_ENDPOINT = 'OSS_ENDPOINT', 32 | OSS_REGION = 'OSS_REGION', 33 | OSS_BUCKET = 'OSS_BUCKET', 34 | } 35 | 36 | export enum MiNiOConfigEnum { 37 | MINIO_ENDPOINT = 'MINIO_ENDPOINT', 38 | MINIO_PORT = 'MINIO_PORT', 39 | MINIO_ACCESS_KEY = 'MINIO_ACCESS_KEY', 40 | MINIO_SECRET_KEY = 'MINIO_SECRET_KEY', 41 | MINIO_BUCKET = 'MINIO_BUCKET', 42 | } 43 | 44 | export enum GitHubLoginConfigEnum { 45 | GITHUB_CLIENT_ID = 'GITHUB_CLIENT_ID', 46 | GITHUB_CLIENT_SECRET = 'GITHUB_CLIENT_SECRET', 47 | GITHUB_CLIENT_URL = 'GITHUB_CLIENT_URL', 48 | GITHUB_USER_INFO_URL = 'GITHUB_USER_INFO_URL', 49 | GITHUB_CALLBACK_URL = 'GITHUB_CALLBACK_URL', 50 | } 51 | -------------------------------------------------------------------------------- /src/core/interceptor/transform.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import { FastifyReply } from 'fastify'; 3 | import { Observable } from 'rxjs'; 4 | import { map } from 'rxjs/operators'; 5 | import { getReasonPhrase } from 'http-status-codes'; 6 | 7 | import { getCurrentTimestamp } from '@/utils'; 8 | 9 | @Injectable() 10 | export class TransformInterceptor implements NestInterceptor { 11 | intercept(context: ExecutionContext, next: CallHandler): Observable { 12 | // 获取Fastify的响应对象 13 | const response: FastifyReply = context.switchToHttp().getResponse(); 14 | 15 | return next.handle().pipe( 16 | map((data: any) => { 17 | // 检查数据是否已经是格式化过的响应 18 | const isSkip: boolean = this.checkSkipTransform(response); 19 | 20 | if (isSkip) { 21 | return data; 22 | } 23 | 24 | // 获取响应状态码,如果没有则默认200 25 | const statusCode = response.statusCode || 200; 26 | // 获取对应状态码的标准消息 27 | const message = getReasonPhrase(statusCode); 28 | 29 | // 设置响应状态码 30 | response.status(statusCode); 31 | 32 | // 构造标准响应格式 33 | return { 34 | code: statusCode, 35 | message: data?.message || message, 36 | data: (data?.data ? data.data : data) ?? null, 37 | timestamp: getCurrentTimestamp(), 38 | }; 39 | }), 40 | ); 41 | } 42 | 43 | // 检查是否应跳过转换 44 | checkSkipTransform(response: FastifyReply): boolean { 45 | // 过滤掉监控暴露的接口 46 | return response.request.url.includes('/metrics'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/core/guard/auth.guard.ts: -------------------------------------------------------------------------------- 1 | // src/auth/guards/custom-auth.guard.ts 2 | import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; 3 | import { JwtService } from '@nestjs/jwt'; 4 | import { Reflector } from '@nestjs/core'; 5 | import { FastifyRequest } from 'fastify'; 6 | 7 | import { UserService } from '@/api/user/user.service'; 8 | 9 | @Injectable() 10 | export class JwtAuthGuard implements CanActivate { 11 | constructor( 12 | private reflector: Reflector, 13 | private jwtService: JwtService, 14 | private usersService: UserService, 15 | ) {} 16 | 17 | async canActivate(context: ExecutionContext): Promise { 18 | const request = context 19 | .switchToHttp() 20 | .getRequest(); 21 | const headers = request.headers; 22 | 23 | // 获取Authorization头信息 24 | const authHeader = headers.authorization; 25 | 26 | if (!authHeader) { 27 | throw new UnauthorizedException('Authorization header missing'); 28 | } 29 | 30 | const token = authHeader.split(' ')[1]; 31 | 32 | if (!token) { 33 | throw new UnauthorizedException('Token missing'); 34 | } 35 | 36 | try { 37 | // 验证JWT令牌并解析用户信息 38 | const decoded = this.jwtService.verify(token); 39 | request.user = decoded; // 将用户信息附加到请求对象 40 | 41 | // 检查数据库中是否存在该用户 42 | const user = await this.usersService.findUserByEmail(decoded.email); 43 | 44 | if (!user) { 45 | throw new UnauthorizedException('User not found'); 46 | } 47 | 48 | return true; // 如果验证通过,返回true 49 | } catch (error) { 50 | throw new UnauthorizedException('Invalid token'); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 5 | import { fastifyMultipart } from '@fastify/multipart'; 6 | import { WsAdapter } from '@nestjs/platform-ws'; 7 | 8 | import { AppModule } from './app.module'; 9 | // import metadata from './metadata'; 10 | import { registerFastifyPlugin } from './common/fastify'; 11 | 12 | async function bootstrap() { 13 | const app = await NestFactory.create( 14 | AppModule, 15 | new FastifyAdapter({ bodyLimit: 50 * 1024 * 1024 }), 16 | { 17 | snapshot: true, 18 | }, 19 | ); 20 | 21 | await registerFastifyPlugin(app, fastifyMultipart); 22 | 23 | app.enableCors({ 24 | origin: ['http://localhost:3000', 'https://online-edit-web.vercel.app/'], 25 | methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', 26 | credentials: true, 27 | allowedHeaders: 'Content-Type, Accept, Authorization', 28 | }); 29 | 30 | app.setGlobalPrefix('api/v1/'); 31 | 32 | app.useWebSocketAdapter(new WsAdapter(app)); 33 | 34 | app.useGlobalPipes( 35 | new ValidationPipe({ 36 | whitelist: true, 37 | transform: true, 38 | }), 39 | ); 40 | 41 | const config = new DocumentBuilder().setTitle('接口文档').setVersion('1.0').build(); 42 | 43 | /** @see https://github.com/nestjs/swagger/issues/2493 */ 44 | // await SwaggerModule.loadPluginMetadata(metadata); 45 | 46 | const document = SwaggerModule.createDocument(app, config); 47 | SwaggerModule.setup('docs', app, document, { 48 | jsonDocumentUrl: 'openApiJson', 49 | }); 50 | 51 | await app.listen(8080, '0.0.0.0'); 52 | } 53 | 54 | bootstrap(); 55 | -------------------------------------------------------------------------------- /src/common/email/email.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, InternalServerErrorException } from '@nestjs/common'; 2 | import * as nodemailer from 'nodemailer'; 3 | import type { Transporter } from 'nodemailer'; 4 | import { ConfigService } from '@nestjs/config'; 5 | 6 | import loadEmailConfig from '../../config/email.config'; 7 | import { LoggerService } from '../logs/logs.service'; 8 | 9 | @Injectable() 10 | export class EmailService { 11 | private transporter: Transporter; 12 | private emailConfig: ReturnType; 13 | 14 | constructor( 15 | private readonly configService: ConfigService, 16 | private readonly logger: LoggerService, 17 | ) { 18 | this.emailConfig = loadEmailConfig(this.configService); 19 | 20 | this.transporter = nodemailer.createTransport({ 21 | host: this.emailConfig.host, 22 | port: this.emailConfig.port, 23 | secure: false, 24 | auth: { 25 | user: this.emailConfig.authUser, 26 | pass: this.emailConfig.authPass, 27 | }, 28 | }); 29 | 30 | this.verifyTransporter(); 31 | } 32 | 33 | private async verifyTransporter(): Promise { 34 | try { 35 | await this.transporter.verify(); 36 | } catch (error) { 37 | this.logger.error(error); 38 | throw new InternalServerErrorException('Email transporter verification failed'); 39 | } 40 | } 41 | 42 | async sendMail(to: string, subject: string, text: string, html?: string): Promise { 43 | try { 44 | const mailOptions = { 45 | from: this.emailConfig.authUser, 46 | to, 47 | subject, 48 | text, 49 | html, 50 | }; 51 | 52 | await this.transporter.sendMail(mailOptions); 53 | } catch (error) { 54 | this.logger.error(error); 55 | throw new InternalServerErrorException('Failed to send email'); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to CentOS Server 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | 16 | - name: Copy files to server 17 | uses: appleboy/scp-action@master 18 | with: 19 | host: ${{ secrets.SERVER_HOST }} 20 | username: ${{ secrets.SERVER_USERNAME }} 21 | password: ${{ secrets.SERVER_PASSWORD }} 22 | source: './' 23 | target: /home/apps-server/online-editor-server/ 24 | 25 | - name: SSH to server and restart application 26 | uses: appleboy/ssh-action@master 27 | with: 28 | host: ${{ secrets.SERVER_HOST }} 29 | username: ${{ secrets.SERVER_USERNAME }} 30 | password: ${{ secrets.SERVER_PASSWORD }} 31 | script: | 32 | # 1. 添加 pnpm 和 pm2 到 PATH 33 | export PATH="$HOME/.local/share/pnpm:/usr/local/bin:$PATH" 34 | 35 | # 2. 安装 pnpm 9.4.0 版本,如果未安装或版本不匹配 36 | if ! command -v pnpm &> /dev/null || [ "$(pnpm -v)" != "9.4.0" ]; then 37 | echo "Installing pnpm 9.4.0..." 38 | curl -fsSL https://get.pnpm.io/install.sh | env PNPM_VERSION=9.4.0 sh - 39 | export PATH="$HOME/.local/share/pnpm:$PATH" 40 | fi 41 | 42 | # 3. 安装 pm2,如果未安装 43 | if ! command -v pm2 &> /dev/null; then 44 | echo "Installing pm2..." 45 | npm install -g pm2 46 | fi 47 | 48 | # 4. 进入项目目录,安装依赖并构建项目 49 | cd /home/apps-server/online-editor-server 50 | pnpm install 51 | pnpm build 52 | 53 | # 5. 启动或重启应用 54 | pm2 start ecosystem.config.js --env production --name online-editor-server || pm2 restart online-editor-server 55 | -------------------------------------------------------------------------------- /src/api/auth/dto/auto.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEmail, IsNotEmpty, IsNumber, IsString } from 'class-validator'; 3 | 4 | export class SendVerificationCodeDto { 5 | @ApiProperty({ 6 | example: '2042204285@qq.com', 7 | description: 'The account to send verification code to', 8 | }) 9 | @IsNotEmpty({ message: '邮箱地址不能为空' }) 10 | @IsString({ message: '事件必须是字符串' }) 11 | account: string; 12 | } 13 | 14 | export class VerificationResponseDto { 15 | @ApiProperty({ example: 'some-uuid', description: 'The verification ID' }) 16 | verificationId: string; 17 | } 18 | 19 | export class SendVerificationCodeResponseDto { 20 | @ApiProperty({ description: '发送状态', example: 'success' }) 21 | status: string; 22 | 23 | @ApiProperty({ description: '验证码有效期(秒)', example: 300 }) 24 | expiresIn: number; 25 | } 26 | 27 | export class EmailLoginDto { 28 | @ApiProperty({ description: 'email账号', example: '2042204285@qq.com' }) 29 | @IsNotEmpty({ message: 'email 不能为空' }) 30 | @IsString({ message: 'email 必须为字符串' }) 31 | @IsEmail({}, { message: 'email 必须是有效的邮箱地址' }) 32 | email: string; 33 | 34 | @ApiProperty({ description: '验证码', example: '123456' }) 35 | @IsNotEmpty({ message: '验证码不能为空' }) 36 | @IsString({ message: '验证码必须为字符串' }) 37 | captcha: string; 38 | } 39 | 40 | export class LoginResponseDto { 41 | @ApiProperty({ description: '访问令牌', example: 'your_access_token' }) 42 | @IsNotEmpty({ message: '访问令牌不能为空' }) 43 | @IsString({ message: '访问令牌必须为字符串' }) 44 | access_token: string; 45 | 46 | @ApiProperty({ description: '刷新令牌', example: 'your_refresh_token' }) 47 | @IsNotEmpty({ message: '刷新令牌不能为空' }) 48 | @IsString({ message: '刷新令牌必须为字符串' }) 49 | refresh_token: string; 50 | 51 | @ApiProperty({ description: '令牌过期时间(秒)', example: 604800 }) 52 | @IsNotEmpty({ message: '令牌过期时间不能为空' }) 53 | @IsNumber({}, { message: '令牌过期时间必须为数字' }) 54 | expiresIn: number; 55 | } 56 | -------------------------------------------------------------------------------- /src/common/dto/response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { getCurrentTimestamp } from '@/utils'; 4 | 5 | export class ResponseDto { 6 | @ApiProperty({ example: 200, description: 'HTTP status code' }) 7 | code?: number; 8 | 9 | @ApiProperty({ 10 | example: 'Created', 11 | description: 'Message describing the result of the operation', 12 | }) 13 | message?: string; 14 | 15 | @ApiProperty({ description: 'The data returned by the API', nullable: true }) 16 | data?: T; 17 | 18 | @ApiProperty({ 19 | example: getCurrentTimestamp(), 20 | description: 'Current timestamp', 21 | }) 22 | timestamp?: number; 23 | } 24 | 25 | export class RequestDetailsDto { 26 | @ApiProperty({ description: 'Query parameters', type: 'object' }) 27 | query: Record; 28 | 29 | @ApiProperty({ description: 'Request body', type: 'object' }) 30 | body: Record; 31 | 32 | @ApiProperty({ description: 'Route parameters', type: 'object' }) 33 | params: Record; 34 | 35 | @ApiProperty({ example: 'POST', description: 'HTTP method' }) 36 | method: string; 37 | 38 | @ApiProperty({ example: '/api/v1/auth/login/email', description: 'Request URL' }) 39 | url: string; 40 | 41 | @ApiProperty({ example: getCurrentTimestamp(), description: 'Request timestamp' }) 42 | timestamp: number; 43 | 44 | @ApiProperty({ example: '::1', description: 'Client IP address' }) 45 | ip: string; 46 | } 47 | 48 | export class ErrorResponseDto { 49 | @ApiProperty({ example: 401, description: 'HTTP status code' }) 50 | code: number; 51 | 52 | @ApiProperty({ 53 | example: '验证码无效。', 54 | description: 'Error message describing the result of the operation', 55 | }) 56 | message: string; 57 | 58 | @ApiProperty({ 59 | type: RequestDetailsDto, 60 | description: 'Details of the request that caused the error', 61 | }) 62 | data: RequestDetailsDto; 63 | } 64 | -------------------------------------------------------------------------------- /src/core/decorate/upload.decorators.ts: -------------------------------------------------------------------------------- 1 | // custom-decorators.ts 2 | import { applyDecorators, HttpStatus } from '@nestjs/common'; 3 | import { ApiOperation, ApiResponse, ApiConsumes, ApiBody } from '@nestjs/swagger'; 4 | 5 | export default function ApiFileUploadDecorate(description: string, single: boolean = true) { 6 | return applyDecorators( 7 | ApiOperation({ summary: `${single ? '单' : '多'}文件上传`, description }), 8 | ApiResponse({ 9 | status: HttpStatus.OK, 10 | description: '文件上传成功,返回文件信息。', 11 | schema: { 12 | type: 'object', 13 | properties: { 14 | url: { type: 'string', description: '上传成功后的文件URL' }, 15 | urls: { 16 | type: 'array', 17 | items: { type: 'string', description: '上传成功后的文件URL数组' }, 18 | }, 19 | }, 20 | }, 21 | }), 22 | ApiResponse({ 23 | status: HttpStatus.BAD_REQUEST, 24 | description: '请求不正确,无法处理文件。', 25 | }), 26 | ApiResponse({ 27 | status: HttpStatus.FORBIDDEN, 28 | description: '没有权限执行此操作。', 29 | }), 30 | ApiConsumes('multipart/form-data'), 31 | ApiBody({ 32 | description: `${single ? '单个' : '多个'}文件上传`, 33 | required: true, 34 | schema: { 35 | type: 'object', 36 | properties: { 37 | [single ? 'file' : 'files']: { 38 | type: single ? 'string' : 'array', 39 | items: single ? undefined : { type: 'string', format: 'binary' }, 40 | format: single ? 'binary' : undefined, 41 | description: `要上传的${single ? '文件' : '文件数组'}`, 42 | }, 43 | bucketName: { 44 | type: 'string', 45 | description: '存储桶名称', 46 | }, 47 | fileName: single 48 | ? { 49 | type: 'string', 50 | description: '文件名称', 51 | } 52 | : undefined, 53 | }, 54 | }, 55 | }), 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/core/filter/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common'; 2 | import { InjectMetric } from '@willsoto/nestjs-prometheus'; 3 | import { FastifyReply, FastifyRequest } from 'fastify'; 4 | import { Counter } from 'prom-client'; 5 | import { LoggerService } from 'src/common/logs/logs.service'; 6 | 7 | import { ErrorResponseDto, RequestDetailsDto } from '@/common/dto/response.dto'; 8 | import { getCurrentTimestamp } from '@/utils'; 9 | 10 | @Catch(HttpException) 11 | export class HttpExceptionFilter implements ExceptionFilter { 12 | constructor( 13 | @InjectMetric('http_exception_total') 14 | private readonly prometheusCounter: Counter, 15 | private readonly logger: LoggerService, 16 | ) {} 17 | 18 | async catch(exception: HttpException, host: ArgumentsHost): Promise { 19 | const ctx = host.switchToHttp(); 20 | const response = ctx.getResponse(); 21 | const request = ctx.getRequest(); 22 | const { url, method } = request; 23 | const status = exception.getStatus(); 24 | 25 | // 监控异常 26 | this.prometheusCounter.labels(method, url, status.toString()).inc(); 27 | 28 | // 记录错误日志 29 | this.logger.error( 30 | { 31 | message: exception.message, 32 | timestamp: getCurrentTimestamp(), 33 | path: url, 34 | status, 35 | }, 36 | 'http错误', 37 | ); 38 | 39 | // 构建错误响应数据 40 | const requestDetails: RequestDetailsDto = { 41 | query: request.query, 42 | body: request.body, 43 | params: request.params, 44 | method: request.method, 45 | url: request.url, 46 | timestamp: getCurrentTimestamp(), 47 | ip: request.ip, 48 | }; 49 | 50 | const errorResponse: ErrorResponseDto = { 51 | code: status, 52 | message: exception.message, 53 | data: requestDetails, 54 | }; 55 | 56 | // 发送异常响应 57 | response.status(status).send(errorResponse); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Logger, Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; 5 | import { EventEmitterModule } from '@nestjs/event-emitter'; 6 | import { ScheduleModule } from '@nestjs/schedule'; 7 | 8 | import { LogsModule } from './common/logs/logs.module'; 9 | import { TasksModule } from './common/tasks/tasks.module'; 10 | import { TimeoutInterceptor } from './core/interceptor/timeout.interceptor'; 11 | import { AllExceptionFilter } from './core/filter/all-exception.filter'; 12 | import { TransformInterceptor } from './core/interceptor/transform.interceptor'; 13 | import { UserModule } from './api/user/user.module'; 14 | import { AuthModule } from './api/auth/auth.module'; 15 | import { CollaborateDocModule } from './api/collaborate-doc/collaborate-doc.module'; 16 | import { CodeQuestionsModule } from './api/code-questions/code-questions.module'; 17 | 18 | import loadDatabaseConfig from '@/config/mongo.config'; 19 | 20 | const NODE_ENV = process.env.NODE_ENV === 'production' ? 'production' : 'development'; 21 | 22 | console.log(NODE_ENV); 23 | 24 | @Module({ 25 | imports: [ 26 | ConfigModule.forRoot({ 27 | isGlobal: true, 28 | envFilePath: `.env.${NODE_ENV}`, 29 | }), 30 | MongooseModule.forRootAsync({ 31 | inject: [ConfigService], 32 | useFactory: loadDatabaseConfig, 33 | }), 34 | EventEmitterModule.forRoot(), 35 | LogsModule, 36 | TasksModule, 37 | UserModule, 38 | AuthModule, 39 | CollaborateDocModule, 40 | CodeQuestionsModule, 41 | ScheduleModule.forRoot(), 42 | ], 43 | controllers: [], 44 | providers: [ 45 | Logger, 46 | { 47 | provide: APP_INTERCEPTOR, 48 | useClass: TimeoutInterceptor, 49 | }, 50 | { 51 | provide: APP_FILTER, 52 | useClass: AllExceptionFilter, 53 | }, 54 | { 55 | provide: APP_INTERCEPTOR, 56 | useClass: TransformInterceptor, 57 | }, 58 | ], 59 | exports: [Logger], 60 | }) 61 | export class AppModule {} 62 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Regression.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F4A5 Regression" 2 | description: 'Report an unexpected behavior while upgrading your application!' 3 | labels: ['needs triage'] 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: 'Is there an existing issue that is already proposing this?' 8 | description: 'Please search [here](./?q=is%3Aissue) to see if an issue already exists for the feature you are requesting' 9 | options: 10 | - label: 'I have searched the existing issues' 11 | required: true 12 | 13 | - type: input 14 | attributes: 15 | label: 'Potential Commit/PR that introduced the regression' 16 | description: 'If you have time to investigate, what PR/date/version introduced this issue' 17 | placeholder: 'PR #123 or commit 5b3c4a4' 18 | 19 | - type: input 20 | attributes: 21 | label: 'Versions' 22 | description: 'From which version of `create-neat` to which version you are upgrading' 23 | placeholder: '1.0.0 -> 1.1.0' 24 | 25 | - type: textarea 26 | validations: 27 | required: true 28 | attributes: 29 | label: 'Describe the regression' 30 | description: 'A clear and concise description of what the regression is' 31 | 32 | - type: textarea 33 | attributes: 34 | label: 'Minimum reproduction code' 35 | description: | 36 | Please share a git repo, a gist, or step-by-step instructions. [Wtf is a minimum reproduction?](https://jmcdo29.github.io/wtf-is-a-minimum-reproduction) 37 | **Tip:** If you leave a minimum repository, we will understand your issue faster! 38 | value: | 39 | ```ts 40 | 41 | ``` 42 | 43 | - type: textarea 44 | validations: 45 | required: true 46 | attributes: 47 | label: 'Expected behavior' 48 | description: 'A clear and concise description of what you expected to happend (or code)' 49 | 50 | - type: textarea 51 | attributes: 52 | label: 'Other' 53 | description: | 54 | Anything else relevant? eg: Logs, OS version, IDE, package manager, etc. 55 | **Tip:** You can attach images, recordings or log files by clicking this area to highlight it and then dragging files in 56 | -------------------------------------------------------------------------------- /src/utils/file.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | 3 | /** 4 | * 计算文件hash值 5 | * @param buffer 6 | * @returns 文件类型 7 | */ 8 | export function calculateHash(buffer: Buffer): string { 9 | const hash = crypto.createHash('sha256'); 10 | hash.update(buffer); 11 | 12 | return hash.digest('hex'); 13 | } 14 | 15 | /** 16 | * 判断文件类型 17 | * @param mimetype 18 | * @returns 文件类型 19 | */ 20 | export function determineFileType(mimetype: string): string { 21 | // 图片类型 22 | const imageMimeTypes = [ 23 | 'image/jpeg', 24 | 'image/png', 25 | 'image/gif', 26 | 'image/bmp', 27 | 'image/svg+xml', 28 | 'image/tiff', 29 | 'image/webp', 30 | 'image/vnd.microsoft.icon', 31 | ]; 32 | 33 | // 文档类型 34 | const documentMimeTypes = [ 35 | 'application/pdf', 36 | 'application/msword', 37 | 'text/plain', 38 | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 39 | 'application/vnd.ms-excel', 40 | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 41 | 'application/vnd.ms-powerpoint', 42 | 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 43 | 'text/csv', 44 | 'text/html', 45 | 'application/rtf', 46 | 'application/vnd.oasis.opendocument.text', 47 | 'application/vnd.oasis.opendocument.spreadsheet', 48 | 'application/vnd.oasis.opendocument.presentation', 49 | ]; 50 | 51 | // 音频类型 52 | const audioMimeTypes = [ 53 | 'audio/mpeg', 54 | 'audio/ogg', 55 | 'audio/wav', 56 | 'audio/webm', 57 | 'audio/aac', 58 | 'audio/midi', 59 | 'audio/x-midi', 60 | 'audio/flac', 61 | ]; 62 | 63 | // 视频类型 64 | const videoMimeTypes = [ 65 | 'video/mp4', 66 | 'video/mpeg', 67 | 'video/ogg', 68 | 'video/webm', 69 | 'video/x-msvideo', 70 | 'video/x-ms-wmv', 71 | 'video/quicktime', 72 | 'video/x-flv', 73 | 'video/x-matroska', 74 | ]; 75 | 76 | // 判断MIME类型属于哪一类 77 | if (imageMimeTypes.includes(mimetype)) { 78 | return 'image'; 79 | } else if (documentMimeTypes.includes(mimetype)) { 80 | return 'docs'; 81 | } else if (audioMimeTypes.includes(mimetype)) { 82 | return 'audio'; 83 | } else if (videoMimeTypes.includes(mimetype)) { 84 | return 'video'; 85 | } else { 86 | return 'docs'; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/core/filter/all-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; 2 | import { HttpAdapterHost } from '@nestjs/core'; 3 | import * as requestIp from '@supercharge/request-ip'; 4 | 5 | import { LoggerService } from '../../common/logs/logs.service'; 6 | 7 | import { getCurrentTimestamp } from '@/utils'; 8 | 9 | interface HttpExceptionResponse { 10 | statusCode: number; 11 | message: any; 12 | error: string; 13 | } 14 | 15 | const getErrorMessage = (exception: T): any => { 16 | if (exception instanceof HttpException) { 17 | const errorResponse = exception.getResponse(); 18 | 19 | return (errorResponse as HttpExceptionResponse).message || exception.message; 20 | } else { 21 | return String(exception); 22 | } 23 | }; 24 | 25 | @Catch() 26 | export class AllExceptionFilter implements ExceptionFilter { 27 | constructor( 28 | private readonly logger: LoggerService, 29 | private readonly httpAdapterHost: HttpAdapterHost, 30 | ) {} 31 | 32 | catch(exception: HttpException, host: ArgumentsHost) { 33 | const errorMessage = getErrorMessage(exception); 34 | const errorStackTrace = exception.stack.split('\n'); 35 | 36 | const { httpAdapter } = this.httpAdapterHost; 37 | const ctx = host.switchToHttp(); 38 | const request = ctx.getRequest(); 39 | const response = ctx.getResponse(); 40 | 41 | let httpStatus: number; 42 | 43 | try { 44 | httpStatus = exception.getStatus() || HttpStatus.INTERNAL_SERVER_ERROR; 45 | } catch (e) { 46 | httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; 47 | } 48 | 49 | const userIp = requestIp.getClientIp(request); 50 | const responseBody = { 51 | code: httpStatus, 52 | message: errorMessage, 53 | data: { 54 | query: request.query, 55 | body: request.body, 56 | params: request.params, 57 | method: request.method, 58 | url: request.url, 59 | timestamp: getCurrentTimestamp(), 60 | ip: userIp, 61 | }, 62 | }; 63 | this.logger.error( 64 | { 65 | ...responseBody, 66 | headers: request.headers, 67 | stackTrace: errorStackTrace, 68 | }, 69 | '错误日志', 70 | ); 71 | 72 | httpAdapter.reply(response, responseBody, httpStatus); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/api/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Body, HttpCode, HttpStatus, HttpException } from '@nestjs/common'; 2 | import { ApiOperation, ApiTags } from '@nestjs/swagger'; 3 | 4 | import { AuthService } from './auth.service'; 5 | import { 6 | EmailLoginDto, 7 | LoginResponseDto, 8 | SendVerificationCodeDto, 9 | SendVerificationCodeResponseDto, 10 | } from './dto/auto.dto'; 11 | 12 | import { ResponseDto } from '@/common/dto/response.dto'; 13 | import { LoginException } from '@/core/exceptions/login.exception'; 14 | import { ApiResponseWithDto } from '@/core/decorate/api-response.decorator'; 15 | 16 | @Controller('auth') 17 | @ApiTags('Auth') 18 | export class AuthController { 19 | constructor(private readonly authService: AuthService) {} 20 | 21 | @Post('send') 22 | @HttpCode(HttpStatus.OK) 23 | @ApiOperation({ summary: '发送邮箱验证码' }) 24 | @ApiResponseWithDto(SendVerificationCodeResponseDto, '发送验证码成功', HttpStatus.OK) 25 | async sendVerificationCode( 26 | @Body() sendVerificationCodeDto: SendVerificationCodeDto, 27 | ): Promise> { 28 | try { 29 | return await this.authService.sendVerificationCode(sendVerificationCodeDto); 30 | } catch (error) { 31 | throw new HttpException( 32 | { statusCode: HttpStatus.BAD_REQUEST, message: error.message, data: null }, 33 | HttpStatus.BAD_REQUEST, 34 | ); 35 | } 36 | } 37 | 38 | @Post('login/email') 39 | @HttpCode(HttpStatus.OK) 40 | @ApiOperation({ summary: '邮箱验证码登录' }) 41 | @ApiResponseWithDto(LoginResponseDto, '登录成功', HttpStatus.OK) 42 | async emailLogin(@Body() loginDto: EmailLoginDto): Promise> { 43 | try { 44 | return await this.authService.emailLogin(loginDto); 45 | } catch (error) { 46 | console.log(error); 47 | 48 | if (error instanceof LoginException) { 49 | throw new HttpException( 50 | { statusCode: HttpStatus.UNAUTHORIZED, message: error.message, data: null }, 51 | HttpStatus.UNAUTHORIZED, 52 | ); 53 | } 54 | 55 | throw new HttpException( 56 | { 57 | statusCode: HttpStatus.INTERNAL_SERVER_ERROR, 58 | message: 'Internal Server Error', 59 | data: null, 60 | }, 61 | HttpStatus.INTERNAL_SERVER_ERROR, 62 | ); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/api/user/dto/send-friend-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEnum, 3 | IsMongoId, 4 | IsOptional, 5 | IsString, 6 | Length, 7 | IsNotEmpty, 8 | MinLength, 9 | MaxLength, 10 | IsNumber, 11 | IsIn, 12 | } from 'class-validator'; 13 | import { Types } from 'mongoose'; 14 | 15 | import { FriendRequestStatus } from '@/common/types'; 16 | 17 | export class CreateFriendRequestDto { 18 | @IsMongoId({ message: 'Invalid receiver ID' }) 19 | receiverId: string; 20 | 21 | @IsString() 22 | @Length(10, 500, { message: 'Description must be between 10 and 500 characters' }) 23 | description: string; 24 | 25 | @IsOptional() 26 | @IsString() 27 | @Length(0, 100, { message: 'Remark must be less than 100 characters' }) 28 | remark?: string; // 可选字段:备注信息 29 | } 30 | 31 | export class UpdateFriendRequestStatusDto { 32 | @IsNotEmpty({ message: 'Status is required and cannot be empty' }) 33 | @IsIn([FriendRequestStatus.ACCEPTED, FriendRequestStatus.REJECTED], { 34 | message: `Status must be one of the following: ${FriendRequestStatus.ACCEPTED}, ${FriendRequestStatus.REJECTED}`, 35 | }) 36 | status: FriendRequestStatus; 37 | 38 | @IsOptional() 39 | @IsString({ message: 'Remark must be a string' }) 40 | @MaxLength(500, { message: 'Remark must be at most 500 characters long' }) 41 | remark?: string; // 可选的备注字段 42 | } 43 | 44 | export class FriendRequestDto { 45 | @IsString() 46 | @IsNotEmpty({ message: 'MongoDB 的 _id 是必填字段' }) 47 | _id: string; 48 | 49 | @IsMongoId() 50 | @IsNotEmpty({ message: '发起请求的用户ID不能为空' }) 51 | senderId: Types.ObjectId; // 发起请求的用户ID,表示发送好友请求的用户 52 | 53 | @IsMongoId() 54 | @IsNotEmpty({ message: '接收请求的用户ID不能为空' }) 55 | receiverId: Types.ObjectId; // 接收请求的用户ID,表示接收好友请求的用户 56 | 57 | @IsString() 58 | @IsNotEmpty({ message: '描述不能为空' }) 59 | @MinLength(10, { message: '描述的长度必须至少为10个字符' }) 60 | @MaxLength(500, { message: '描述的长度不能超过500个字符' }) 61 | description: string; // 请求描述,描述该好友请求的目的或信息,长度必须在10到500个字符之间 62 | 63 | @IsNumber() 64 | @IsNotEmpty({ message: '创建时间不能为空' }) 65 | createdAt: number; // 创建时间,记录该好友请求的创建时间戳 66 | 67 | @IsEnum(FriendRequestStatus, { message: '状态必须是有效的好友请求状态' }) 68 | @IsNotEmpty({ message: '状态不能为空' }) 69 | status: FriendRequestStatus; // 请求状态,表示好友请求当前的处理状态(例如:PENDING、ACCEPTED、REJECTED) 70 | 71 | @IsString() 72 | @IsOptional() 73 | remark?: string; // 备注信息,可选字段,用于记录请求的附加信息或备注,可为空 74 | } 75 | -------------------------------------------------------------------------------- /CONTRIBUTING-zh.md: -------------------------------------------------------------------------------- 1 | # 贡献指南 2 | 3 | 非常感谢你有兴趣为本项目贡献代码!为了使贡献过程尽可能顺利,请遵循以下指南。 4 | 5 | ## 如何开始 6 | 7 | ### 1. Fork 仓库 8 | 9 | 首先,fork 仓库到你的 GitHub 账户中。这会创建一个你自己的仓库副本。 10 | 11 | ### 2. 克隆仓库 12 | 13 | 在你的本地机器上克隆你刚刚 fork 的仓库: 14 | 15 | ```bash 16 | git clone https://github.com/你的用户名/项目名.git 17 | cd 项目名 18 | ``` 19 | 20 | ### 3. 添加上游远程仓库 21 | 22 | 为了保持你的仓库与原始仓库同步,请添加上游远程仓库: 23 | 24 | ```bash 25 | git remote add upstream https://github.com/xun082/online-edit-web.git 26 | ``` 27 | 28 | ### 4. 创建新分支 29 | 30 | 在开始工作之前,请确保你创建了一个新的分支: 31 | 32 | ```bash 33 | git checkout -b feature/你的分支名 34 | ``` 35 | 36 | ## 开发流程 37 | 38 | ### 1. 安装依赖 39 | 40 | 在你开始开发之前,请安装所有的依赖: 41 | 42 | ```bash 43 | pnpm install 44 | ``` 45 | 46 | ### 2. 运行项目 47 | 48 | 为了确保你在一个正常运行的环境下进行开发,启动项目: 49 | 50 | ```bash 51 | pnpm dev 52 | ``` 53 | 54 | ### 3. 全局链接项目 55 | 56 | 为了在开发过程中方便地使用和测试你的脚手架命令,可以使用 pnpm link --global 将你的项目全局链接: 57 | 58 | ```bash 59 | pnpm link --global 60 | ``` 61 | 62 | 使用 npm link 的话,命令如下: 63 | 64 | ```bash 65 | npm link 66 | ``` 67 | 68 | 这样,你就可以在任何地方使用你的脚手架命令,而不必每次都从项目目录中运行。 69 | 70 | ### 4. 进行开发 71 | 72 | 请遵循以下开发准则: 73 | 74 | - 确保代码清晰、简洁。 75 | - 遵循项目的代码风格和规范(可以使用 ESLint 和 Prettier)。 76 | - 如果你添加了新功能,请编写相应的测试。 77 | - 如果你修复了 bug,请添加测试来防止将来再次出现。 78 | 79 | ### 4. 提交更改 80 | 81 | 在提交你的更改之前,请确保你进行了适当的代码格式化和 lint: 82 | 83 | ```bash 84 | pnpm lint 85 | pnpm format 86 | ``` 87 | 88 | 然后提交你的更改: 89 | 90 | ```bash 91 | git add . 92 | git commit -m "描述清晰的提交信息" 93 | ``` 94 | 95 | ### 5. 同步你的分支 96 | 97 | 在你准备好提交你的更改之前,请确保你的分支是最新的: 98 | 99 | ```bash 100 | git fetch upstream 101 | git rebase upstream/main 102 | ``` 103 | 104 | ### 6. 推送分支 105 | 106 | 将你的分支推送到你自己的仓库: 107 | 108 | ```bash 109 | git push origin feature/你的分支名 110 | ``` 111 | 112 | ### 7. 创建 Pull Request 113 | 114 | 在 GitHub 上,导航到你 fork 的仓库,点击 "Compare & pull request" 按钮。请确保你详细描述了你所做的更改。 115 | 116 | ## 代码审查 117 | 118 | 所有的 Pull Request 都会被审查。请注意以下几点: 119 | 120 | - 你的代码是否清晰且易于理解。 121 | - 你是否遵循了项目的代码风格和规范。 122 | - 你是否添加了适当的测试。 123 | - 你的更改是否与现有的代码兼容。 124 | 125 | ## 常见问题 126 | 127 | ### 如何报告 Bug? 128 | 129 | 如果你发现了 Bug,请在 GitHub 上创建一个 Issue,并尽可能详细地描述 Bug 及其复现步骤。 130 | 131 | ### 如何请求新功能? 132 | 133 | 如果你有新功能的建议,请在 GitHub 上创建一个 Issue,详细描述你的建议及其潜在的用途。 134 | 135 | ## 联系我们 136 | 137 | 如果你有任何问题或需要帮助,请随时通过邮件 `2042204285@qq.com` 或者微信 `yunmz777` 联系我们,或者在 GitHub 上提问。 138 | -------------------------------------------------------------------------------- /eslint.config.cjs: -------------------------------------------------------------------------------- 1 | const typescriptEslintPlugin = require('@typescript-eslint/eslint-plugin'); 2 | const typescriptEslintParser = require('@typescript-eslint/parser'); 3 | const importPlugin = require('eslint-plugin-import'); 4 | const prettier = require('eslint-plugin-prettier'); 5 | const { resolve } = require('path'); 6 | 7 | module.exports = [ 8 | { 9 | ignores: ['node_modules/**', '.next/**', './eslint.config.js'], 10 | }, 11 | { 12 | files: ['**/*.{js,jsx,ts,tsx}'], 13 | languageOptions: { 14 | ecmaVersion: 2020, 15 | sourceType: 'module', 16 | parser: typescriptEslintParser, 17 | parserOptions: { 18 | project: resolve(__dirname, './tsconfig.json'), 19 | }, 20 | }, 21 | plugins: { 22 | '@typescript-eslint': typescriptEslintPlugin, 23 | import: importPlugin, 24 | prettier, 25 | }, 26 | rules: { 27 | ...typescriptEslintPlugin.configs.recommended.rules, 28 | ...prettier.configs.recommended.rules, 29 | 'prettier/prettier': [ 30 | 'error', 31 | { 32 | endOfLine: 'auto', 33 | }, 34 | ], 35 | 'import/order': [ 36 | 'error', 37 | { 38 | groups: [['builtin', 'external'], 'internal', ['parent', 'sibling', 'index']], 39 | 'newlines-between': 'always', 40 | }, 41 | ], 42 | 'padding-line-between-statements': [ 43 | 'error', 44 | { blankLine: 'always', prev: '*', next: 'return' }, 45 | { blankLine: 'always', prev: 'directive', next: '*' }, 46 | { blankLine: 'any', prev: 'directive', next: 'directive' }, 47 | { blankLine: 'always', prev: 'block', next: '*' }, 48 | { blankLine: 'always', prev: '*', next: 'block' }, 49 | { blankLine: 'always', prev: 'block-like', next: '*' }, 50 | { blankLine: 'always', prev: '*', next: 'block-like' }, 51 | { blankLine: 'always', prev: '*', next: 'function' }, 52 | { blankLine: 'always', prev: 'function', next: '*' }, 53 | { blankLine: 'always', prev: '*', next: ['const', 'let', 'var'] }, 54 | { blankLine: 'any', prev: ['const', 'let', 'var'], next: ['const', 'let', 'var'] }, 55 | ], 56 | 'newline-before-return': 'error', 57 | '@typescript-eslint/no-explicit-any': 'off', 58 | '@typescript-eslint/no-unused-vars': 'warn', 59 | '@typescript-eslint/no-var-requires': 'warn', 60 | }, 61 | settings: { 62 | react: { 63 | version: 'detect', 64 | }, 65 | }, 66 | }, 67 | ]; 68 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUGS.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41B Bug Report" 2 | description: "If something isn't working as expected \U0001F914" 3 | labels: ['needs triage', 'bug'] 4 | body: 5 | - type: textarea 6 | validations: 7 | required: true 8 | attributes: 9 | label: 'Current behavior' 10 | description: 'How the issue manifests?' 11 | 12 | - type: input 13 | validations: 14 | required: true 15 | attributes: 16 | label: 'Minimum reproduction code' 17 | description: 'An URL to some git repository or gist that reproduces this issue. [Wtf is a minimum reproduction?](https://jmcdo29.github.io/wtf-is-a-minimum-reproduction)' 18 | placeholder: 'https://github.com/...' 19 | 20 | - type: textarea 21 | attributes: 22 | label: 'Steps to reproduce' 23 | description: | 24 | How the issue manifests? 25 | You could leave this blank if you alread write this in your reproduction code/repo 26 | placeholder: | 27 | 1. `npm i` 28 | 2. `npm start:dev` 29 | 3. See error... 30 | 31 | - type: textarea 32 | validations: 33 | required: true 34 | attributes: 35 | label: 'Expected behavior' 36 | description: 'A clear and concise description of what you expected to happend (or code)' 37 | 38 | - type: markdown 39 | attributes: 40 | value: | 41 | --- 42 | 43 | - type: input 44 | validations: 45 | required: true 46 | attributes: 47 | label: 'Package version' 48 | description: | 49 | Which version of `@nestjs/cli` are you using? 50 | **Tip**: Make sure that all of yours `@nestjs/*` dependencies are in sync! 51 | placeholder: '8.1.3' 52 | 53 | - type: dropdown 54 | attributes: 55 | label: 'Template Package' 56 | description: 'Which project template are you using?' 57 | options: 58 | - react-web-ts 59 | - vue-web-js 60 | - react-web-js 61 | - vue-web-ts 62 | validations: 63 | required: true 64 | 65 | - type: input 66 | attributes: 67 | label: 'Node.js version' 68 | description: 'Which version of Node.js are you using?' 69 | placeholder: '14.17.6' 70 | 71 | - type: checkboxes 72 | attributes: 73 | label: 'In which operating systems have you tested?' 74 | options: 75 | - label: macOS 76 | - label: Windows 77 | - label: Linux 78 | 79 | - type: markdown 80 | attributes: 81 | value: | 82 | --- 83 | 84 | - type: textarea 85 | attributes: 86 | label: 'Other' 87 | description: | 88 | Anything else relevant? eg: Logs, OS version, IDE, package manager, etc. 89 | **Tip:** You can attach images, recordings or log files by clicking this area to highlight it and then dragging files in 90 | -------------------------------------------------------------------------------- /src/api/user/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { Transform } from 'class-transformer'; 4 | import { Types } from 'mongoose'; 5 | 6 | import { IsMinioUrl } from '@/core/decorate/minio-url.decorator'; 7 | 8 | export class FindUserByEmailDto { 9 | @IsEmail({}, { message: '无效的邮箱地址' }) 10 | @IsNotEmpty({ message: '邮箱地址不能为空' }) 11 | @IsString({ message: 'email 必须为字符串' }) 12 | email: string; 13 | } 14 | 15 | export class createUserDto extends FindUserByEmailDto { 16 | @IsNotEmpty({ message: '密码不能为空' }) 17 | @IsString({ message: '密码必须为字符串' }) 18 | password: string; 19 | 20 | @IsNotEmpty({ message: '验证码不能为空' }) 21 | @IsString({ message: '验证码必须为字符串' }) 22 | code: string; 23 | 24 | @IsString() 25 | @IsNotEmpty({ message: '确认密码不能为空' }) 26 | confirm_password: string; 27 | } 28 | 29 | export class GithubUserDto { 30 | githubId: number; 31 | username: string; 32 | avatar: string; 33 | email: string; 34 | } 35 | 36 | export class UserDto { 37 | @ApiProperty({ description: '用户邮箱' }) 38 | email: string; 39 | 40 | @ApiProperty({ description: '用户名' }) 41 | username: string; 42 | 43 | @ApiProperty({ description: '用户头像' }) 44 | avatar: string; 45 | 46 | @ApiProperty({ description: '创建时间' }) 47 | createdAt: number; 48 | 49 | @ApiProperty({ description: '用户所在地区' }) 50 | region: string; 51 | 52 | @ApiProperty({ description: '用户签名' }) 53 | signature: string; 54 | 55 | @ApiProperty({ description: '用户背景图片' }) 56 | backgroundImage: string; 57 | 58 | @ApiProperty({ description: '用户 ID' }) 59 | @Transform(({ value }) => value.toString()) 60 | _id: Types.ObjectId; 61 | } 62 | 63 | export class UserWithFriendStatusDto extends UserDto { 64 | @ApiProperty({ description: '是否为好友' }) 65 | isFriend: boolean; 66 | } 67 | 68 | export class GitHubAccessToken { 69 | @ApiProperty({ description: '用户邮箱' }) 70 | access_token: string; 71 | 72 | @ApiProperty({ description: '用户名' }) 73 | token_type: string; 74 | 75 | @ApiProperty({ description: '用户头像', default: '1c902bf0-df6b-447f-bb9c-a257b014b1f5' }) 76 | scope: string; 77 | } 78 | 79 | export class UpdateUserDto { 80 | @ApiProperty({ description: '用户名,可选' }) 81 | @IsOptional() 82 | @IsString() 83 | @IsNotEmpty() 84 | username?: string; 85 | 86 | @ApiProperty({ description: '地区,可选' }) 87 | @IsOptional() 88 | @IsString() 89 | @IsNotEmpty() 90 | region?: string; 91 | 92 | @ApiProperty({ description: '个性签名,可选' }) 93 | @IsOptional() 94 | @IsString() 95 | @IsNotEmpty() 96 | signature?: string; 97 | 98 | @ApiProperty({ description: '头像URL,可选' }) 99 | @IsOptional() 100 | @IsMinioUrl() 101 | @IsNotEmpty() 102 | avatar?: string; 103 | 104 | @ApiProperty({ description: '背景图片URL,可选' }) 105 | @IsOptional() 106 | @IsMinioUrl() 107 | @IsNotEmpty() 108 | backgroundImage?: string; 109 | } 110 | -------------------------------------------------------------------------------- /src/api/collaborate-doc/collaborate-doc.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WebSocketGateway, 3 | OnGatewayConnection, 4 | OnGatewayDisconnect, 5 | WebSocketServer, 6 | } from '@nestjs/websockets'; 7 | import { Server, WebSocket } from 'ws'; 8 | import * as Y from 'yjs'; 9 | import { setupWSConnection, setPersistence } from 'y-websocket/bin/utils'; 10 | import { IncomingMessage } from 'http'; 11 | import { MongodbPersistence } from 'y-mongodb-provider'; 12 | 13 | import { CollaborateDocService } from './collaborate-doc.service'; 14 | import { CacheStoreService } from '../cache-store/cache-store.service'; 15 | 16 | @WebSocketGateway({ 17 | path: '/collaborateDoc', 18 | transports: ['websocket'], 19 | cors: { 20 | origin: '*', 21 | }, 22 | }) 23 | export class CollaborateDocGateway implements OnGatewayConnection, OnGatewayDisconnect { 24 | @WebSocketServer() server: Server; 25 | 26 | private docsMap: Map = new Map(); 27 | 28 | constructor( 29 | private readonly collaborateDocService: CollaborateDocService, 30 | private readonly cacheStoreService: CacheStoreService, 31 | ) {} 32 | 33 | async handleConnection(client: WebSocket, request: IncomingMessage) { 34 | const url = new URL(request.url, `http://${request.headers.host}`); 35 | 36 | const record_id = url.searchParams.get('record_id'); 37 | 38 | if (!record_id) { 39 | client.close(); 40 | 41 | return; 42 | } 43 | 44 | setupWSConnection(client, request, { 45 | docName: record_id, 46 | gc: true, 47 | }); 48 | 49 | setPersistence({ 50 | bindState: async (docName: string, ydoc) => { 51 | const persistedYdoc = await this.collaborateDocService.mdb.getYDoc(docName); 52 | 53 | const newUpdates = Y.encodeStateAsUpdate(ydoc); 54 | this.collaborateDocService.mdb.storeUpdate(docName, newUpdates); 55 | Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc)); 56 | 57 | // try { 58 | // const base64Docs = JSON.parse(await this.cacheStoreService.getDoc(docName)); 59 | 60 | // const docUpdates = base64Docs?.map((d) => new Uint8Array(Buffer.from(d, 'base64'))); 61 | 62 | // if (docUpdates && docUpdates.length > 0) { 63 | // ydoc.transact(() => { 64 | // docUpdates.forEach((update) => { 65 | // Y.applyUpdate(ydoc, update.buffer); // 将更新应用到当前 ydoc 文档 66 | // }); 67 | // }); 68 | // } 69 | // } catch (error) { 70 | // console.log('bindState error', error); 71 | // } 72 | 73 | ydoc.on('update', async (update: Uint8Array) => { 74 | // this.cacheStoreService.storeDoc(docName, update); 75 | this.collaborateDocService.mdb.storeUpdate(docName, update); 76 | }); 77 | }, 78 | writeState: () => { 79 | return new Promise((resolve) => { 80 | resolve(true); 81 | }); 82 | }, 83 | }); 84 | } 85 | 86 | handleDisconnect(client: WebSocket) { 87 | // console.log('disconnected', client); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/api/cache-store/cache-store.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import * as Y from 'yjs'; 3 | import { Cron } from '@nestjs/schedule'; 4 | 5 | import { CollaborateDocService } from '../collaborate-doc/collaborate-doc.service'; 6 | 7 | import { RedisService } from '@/common/redis/redis.service'; 8 | 9 | const getDocKey = (docName: string) => `doc:${docName}`; 10 | 11 | @Injectable() 12 | export class CacheStoreService { 13 | constructor( 14 | private readonly redisService: RedisService, 15 | private readonly collaborateDocService: CollaborateDocService, 16 | ) {} 17 | 18 | async storeDoc(docName: string, doc: Uint8Array) { 19 | const key = getDocKey(docName); 20 | const storedDoc = await this.getDoc(docName); 21 | 22 | let updatedDoc: Uint8Array[]; 23 | 24 | if (storedDoc) { 25 | updatedDoc = [...storedDoc, doc]; 26 | } else { 27 | updatedDoc = [doc]; 28 | } 29 | 30 | const base64Docs = updatedDoc.map((d) => Buffer.from(d).toString('base64')); 31 | await this.redisService.set(key, JSON.stringify(base64Docs)); 32 | } 33 | 34 | async getDoc(docName: string) { 35 | const key = getDocKey(docName); 36 | const jsonDoc = (await this.redisService.get(key)) as string; 37 | if (!jsonDoc) return null; 38 | 39 | const base64Docs = JSON.parse(jsonDoc); 40 | 41 | return base64Docs.map((d) => new Uint8Array(Buffer.from(d, 'base64'))); 42 | } 43 | 44 | // @Cron('0 */1 * * * *') 45 | async persistDoc() { 46 | try { 47 | const docKeys = await this.getAllDocKeys(); 48 | 49 | console.log('docKeys', docKeys); 50 | 51 | if (docKeys.length === 0) { 52 | return; 53 | } 54 | 55 | console.log('start persistDoc'); 56 | 57 | const docs = await this.redisService.mget(docKeys); 58 | 59 | // 处理文档持久化任务的数组 60 | const persistTasks = docKeys.map(async (key, index) => { 61 | const docName = key.replace('doc:', ''); // 提取 docName 62 | const base64Docs = JSON.parse(docs[index]); // 从 Redis 获取的 base64 编码的文档数据 63 | const docUpdates = base64Docs.map((d) => new Uint8Array(Buffer.from(d, 'base64'))); 64 | 65 | const ydoc = new Y.Doc(); 66 | 67 | // 合并所有的更新 68 | 69 | ydoc.transact(() => { 70 | docUpdates.forEach((update) => { 71 | Y.applyUpdate(ydoc, update); 72 | }); 73 | }); 74 | 75 | const ytext = ydoc.getText('monaco'); 76 | console.log("Y.Text 'monaco' exists:", !!ytext); // 检查共享对象是否存在 77 | console.log('Text content after updates:', ytext ? ytext.toString() : 'No content'); 78 | 79 | // 持久化合并后的文档到 MongoDB 80 | const persistedDoc = Y.encodeStateAsUpdate(ydoc); 81 | 82 | await this.collaborateDocService.storeUpdate(docName, persistedDoc); 83 | 84 | // 返回删除 Redis 缓存的任务 85 | return key; 86 | }); 87 | 88 | // 等待所有持久化任务完成 89 | const keysToDelete = await Promise.all(persistTasks); 90 | 91 | // 批量删除 Redis 中的缓存 92 | if (keysToDelete.length > 0) { 93 | await this.redisService.del(keysToDelete); 94 | } 95 | } catch (error) { 96 | console.log('persistDoc err', error); 97 | } 98 | } 99 | 100 | async getAllDocKeys() { 101 | return await this.redisService.scanKeys('doc:*'); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/common/logs/logs.service.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { Injectable } from '@nestjs/common'; 3 | import * as winston from 'winston'; 4 | import DailyRotateFile from 'winston-daily-rotate-file'; 5 | 6 | import { ObjectType } from '../types'; 7 | 8 | const transportsHandler = () => { 9 | const transportsList: winston.transport[] = [ 10 | new DailyRotateFile({ 11 | // filename: 'logs/error-%DATE%.log', 12 | filename: path.join(process.cwd(), 'logs', 'error-%DATE%.log'), 13 | // datePattern: 'YYYY-MM-DD-HH', 14 | datePattern: 'YYYY-MM-DD', 15 | zippedArchive: true, 16 | maxSize: '20m', 17 | maxFiles: '14d', 18 | level: 'error', 19 | }), 20 | new DailyRotateFile({ 21 | filename: path.join(process.cwd(), 'logs', 'info-%DATE%.log'), 22 | // 按天存放 23 | datePattern: 'YYYY-MM-DD', 24 | // 按小时来 25 | // datePattern: 'YYYY-MM-DD-HH', 26 | // 自动压缩 27 | zippedArchive: true, 28 | handleExceptions: true, 29 | maxSize: '20m', 30 | maxFiles: '14d', 31 | level: 'silly', 32 | }), 33 | ]; 34 | 35 | if (process.env.NODE_ENV !== 'production') { 36 | transportsList.push(new winston.transports.Console({})); 37 | } 38 | 39 | return transportsList; 40 | }; 41 | 42 | @Injectable() 43 | export class LoggerService { 44 | private logger: winston.Logger; 45 | 46 | constructor() { 47 | this.logger = winston.createLogger({ 48 | level: process.env.NODE_ENV !== 'production' ? 'silly' : 'info', // 根据环境来区分日志级别 49 | format: winston.format.combine( 50 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), 51 | winston.format.colorize(), 52 | // 自定义输出代码格式 53 | winston.format.printf(({ prefix, timestamp, message, level }) => { 54 | return `[${timestamp}]-【${level}】-${prefix ? `-【${prefix}】` : ''} ${message}`; 55 | }), 56 | ), 57 | transports: transportsHandler(), 58 | }); 59 | } 60 | 61 | // log level 0 62 | public error(message: string | ObjectType, prefix = ''): void { 63 | this.logger.error(this.toString(message), { prefix }); 64 | } 65 | 66 | // log level 1 67 | public warn(message: string | ObjectType, prefix = ''): void { 68 | this.logger.warn(this.toString(message), { prefix }); 69 | } 70 | 71 | // log level 2 72 | public info(message: string | ObjectType, prefix = ''): void { 73 | this.logger.info(this.toString(message), { prefix }); 74 | } 75 | 76 | // log level 3 77 | public http(message: string | ObjectType, prefix = ''): void { 78 | this.logger.http(this.toString(message), { prefix }); 79 | } 80 | 81 | // log level 4 82 | public verbose(message: string | ObjectType, prefix = ''): void { 83 | this.logger.verbose(this.toString(message), { prefix }); 84 | } 85 | 86 | // log level 5 87 | public debug(message: string | ObjectType, prefix = ''): void { 88 | this.logger.debug(this.toString(message), { prefix }); 89 | } 90 | 91 | // log level 6 92 | public silly(message: string | ObjectType, prefix = ''): void { 93 | this.logger.silly(this.toString(message), { prefix }); 94 | } 95 | 96 | private toString(message: string | ObjectType): string { 97 | if (typeof message !== 'string') { 98 | return JSON.stringify(message, null, 2); 99 | } else { 100 | return message as string; 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/api/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | import { Model } from 'mongoose'; 4 | import { InjectModel } from '@nestjs/mongoose'; 5 | 6 | import { 7 | EmailLoginDto, 8 | LoginResponseDto, 9 | SendVerificationCodeDto, 10 | SendVerificationCodeResponseDto, 11 | } from './dto/auto.dto'; 12 | import { UserService } from '../user/user.service'; 13 | import { UserDocument, User } from '../user/schema/user.schema'; 14 | 15 | import { RedisService } from '@/common/redis/redis.service'; 16 | import { EmailService } from '@/common/email/email.service'; 17 | import { ResponseDto } from '@/common/dto/response.dto'; 18 | import { LoginException } from '@/core/exceptions/login.exception'; 19 | import { generateDefaultPassword, generateVerificationCode } from '@/utils'; 20 | 21 | @Injectable() 22 | export class AuthService { 23 | constructor( 24 | private readonly redisService: RedisService, 25 | private readonly emailService: EmailService, 26 | private readonly userService: UserService, 27 | private jwtService: JwtService, 28 | @InjectModel(User.name) private userModel: Model, 29 | ) {} 30 | 31 | async sendVerificationCode( 32 | data: SendVerificationCodeDto, 33 | ): Promise> { 34 | const { account } = data; 35 | 36 | const verificationCode = generateVerificationCode(); 37 | 38 | await this.redisService.set(account, verificationCode, 300); 39 | 40 | try { 41 | await this.emailService.sendMail(account, account, verificationCode); 42 | } catch (error) { 43 | throw new LoginException('发送验证码失败,请稍后再试。'); 44 | } 45 | 46 | return { 47 | data: { 48 | status: 'success', 49 | expiresIn: 300, 50 | }, 51 | message: '发送验证码成功', 52 | }; 53 | } 54 | 55 | async emailLogin(data: EmailLoginDto): Promise> { 56 | const { email, captcha } = data; 57 | 58 | // 1. 验证验证码是否有效 59 | const uniqueId = await this.redisService.get(email); 60 | 61 | if (uniqueId !== captcha) { 62 | throw new LoginException('验证码无效。'); 63 | } 64 | 65 | // 2. 查找用户并在不存在时创建用户 66 | const password = generateDefaultPassword(); 67 | const userResult = await this.userModel 68 | .findOneAndUpdate( 69 | { email }, // 查询条件 70 | { $setOnInsert: { email, password, username: 'moment' } }, 71 | { new: true, upsert: true, select: '_id email username' }, 72 | ) 73 | .lean(); 74 | 75 | // 3. 确保 userResult 存在 76 | if (!userResult) { 77 | throw new Error('用户创建或查找失败'); 78 | } 79 | 80 | // 4. 生成 JWT token 81 | const tokens = this.generateTokens(userResult._id.toString(), userResult.email); 82 | 83 | // 5. 返回登录成功的响应 84 | return { 85 | data: { 86 | access_token: tokens.accessToken, 87 | refresh_token: tokens.refreshToken, 88 | expiresIn: 7 * 24 * 60 * 60, // 7 天的有效期 89 | }, 90 | message: '登录成功', 91 | }; 92 | } 93 | 94 | private generateTokens(userId: string, email: string) { 95 | const accessToken = this.jwtService.sign({ 96 | sub: userId, 97 | email: email, 98 | }); 99 | 100 | const refreshToken = this.jwtService.sign({ sub: userId, email: email }, { expiresIn: '7d' }); 101 | 102 | return { accessToken, refreshToken }; 103 | } 104 | 105 | jwtVerify(token: string) { 106 | return this.jwtService.verify(token); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/api/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpCode, 6 | HttpStatus, 7 | Param, 8 | Patch, 9 | Post, 10 | Query, 11 | Request, 12 | UseGuards, 13 | } from '@nestjs/common'; 14 | import { ApiOperation, ApiTags } from '@nestjs/swagger'; 15 | import { AuthGuard } from '@nestjs/passport'; 16 | 17 | import { UserService } from './user.service'; 18 | import { 19 | CreateFriendRequestDto, 20 | FriendRequestDto, 21 | UpdateFriendRequestStatusDto, 22 | } from './dto/send-friend-request.dto'; 23 | import { 24 | FindUserByEmailDto, 25 | UpdateUserDto, 26 | UserDto, 27 | UserWithFriendStatusDto, 28 | } from './dto/user.dto'; 29 | import { FriendDetailsDto } from './dto/friend'; 30 | 31 | import { RequestWithUser } from '@/common/types'; 32 | import { ResponseDto } from '@/common/dto/response.dto'; 33 | import { ApiResponseWithDto } from '@/core/decorate/api-response.decorator'; 34 | 35 | @Controller('user') 36 | @ApiTags('User') 37 | @UseGuards(AuthGuard('jwt')) 38 | export class UserController { 39 | constructor(private readonly userService: UserService) {} 40 | 41 | @Get() 42 | @ApiOperation({ summary: '获取登录用户信息' }) 43 | @ApiResponseWithDto(UserWithFriendStatusDto, '获取登录用户信息', HttpStatus.OK) 44 | async getUserInfo( 45 | @Request() req: RequestWithUser, 46 | @Query('userId') userId?: string, 47 | ): Promise> { 48 | const userInfo = await this.userService.getUserInfo(req.user._id, userId); 49 | 50 | return { 51 | data: userInfo, 52 | }; 53 | } 54 | 55 | @Post('friend/request') 56 | @HttpCode(HttpStatus.CREATED) 57 | @ApiOperation({ summary: '发送好友请求' }) 58 | async sendFriendRequest( 59 | @Request() req: RequestWithUser, 60 | @Body() sendFriendRequestDto: CreateFriendRequestDto, 61 | ): Promise> { 62 | return await this.userService.sendFriendRequest(req.user._id, sendFriendRequestDto); 63 | } 64 | 65 | @Get('friend/requests') 66 | @ApiOperation({ summary: '获取好友请求' }) 67 | async getFriendRequests( 68 | @Request() req: RequestWithUser, 69 | ): Promise> { 70 | return await this.userService.getFriendRequests(req.user._id); 71 | } 72 | 73 | @Get('friends') 74 | @ApiOperation({ summary: '获取好友列表' }) 75 | @ApiResponseWithDto([FriendDetailsDto], '获取好友列表', HttpStatus.OK) 76 | async getFriendsList(@Request() req: RequestWithUser): Promise> { 77 | const data = await this.userService.getFriendsList(req.user._id); 78 | 79 | const result = data.length > 0 ? data : []; 80 | 81 | return { 82 | data: result, 83 | }; 84 | } 85 | 86 | @Patch('friend/requests/:id') 87 | @ApiOperation({ summary: '更新好友请求状态' }) 88 | async updateFriendRequestStatus( 89 | @Param('id') id: string, 90 | @Body() updateFriendRequestDto: UpdateFriendRequestStatusDto, 91 | ): Promise> { 92 | return await this.userService.updateFriendRequestStatus(id, updateFriendRequestDto); 93 | } 94 | 95 | @Get('search') 96 | @HttpCode(HttpStatus.OK) 97 | @ApiOperation({ summary: '根据邮箱搜索用户' }) 98 | @ApiResponseWithDto(UserDto, '获取用户信息', HttpStatus.OK) 99 | async searchUserByEmail( 100 | @Query() query: FindUserByEmailDto, 101 | ): Promise> { 102 | return await this.userService.findUserByEmail(query); 103 | } 104 | 105 | @Patch('update') 106 | @HttpCode(HttpStatus.OK) 107 | @ApiOperation({ summary: '修改用户信息' }) 108 | @ApiResponseWithDto(UpdateUserDto, '修改用户信息', HttpStatus.OK) 109 | async updateUserInfo(@Request() req: RequestWithUser, @Body() updateUserDto: UpdateUserDto) { 110 | return await this.userService.updateUserInfo(req.user._id, updateUserDto); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | Thank you very much for your interest in contributing to this project! To make the contribution process as smooth as possible, please follow the guidelines below. 4 | 5 | ## Getting Started 6 | 7 | ### 1. Fork the Repository 8 | 9 | First, fork the repository to your GitHub account. This will create your own copy of the repository. 10 | 11 | ### 2. Clone the Repository 12 | 13 | Clone the repository you just forked to your local machine: 14 | 15 | ```bash 16 | git clone https://github.com/your-username/repository-name.git 17 | cd repository-name 18 | ``` 19 | 20 | ### 3. Add Upstream Remote 21 | 22 | To keep your repository in sync with the original repository, add the upstream remote: 23 | 24 | ```bash 25 | git remote add upstream https://github.com/xun082/online-edit-web.git 26 | ``` 27 | 28 | ### 4. Create a New Branch 29 | 30 | Before you start working, make sure to create a new branch: 31 | 32 | ```bash 33 | git checkout -b feature/your-branch-name 34 | ``` 35 | 36 | ## Development Workflow 37 | 38 | ### 1. Install Dependencies 39 | 40 | Before you start developing, install all dependencies: 41 | 42 | ```bash 43 | pnpm install 44 | ``` 45 | 46 | ### 2. Run the Project 47 | 48 | To ensure you are developing in a properly running environment, start the project: 49 | 50 | ```bash 51 | pnpm dev 52 | ``` 53 | 54 | ### 3. Globally Link the Project 55 | 56 | To conveniently use and test your scaffold commands during development, you can globally link your project using `pnpm link --global`: 57 | 58 | ```bash 59 | pnpm link --global 60 | ``` 61 | 62 | If using `npm link`, the command is: 63 | 64 | ```bash 65 | npm link 66 | ``` 67 | 68 | This way, you can use your scaffold commands anywhere without having to run them from the project directory each time. 69 | 70 | ### 4. Development Guidelines 71 | 72 | Please follow these development guidelines: 73 | 74 | - Ensure code is clear and concise. 75 | - Follow the project's code style and standards (you can use ESLint and Prettier). 76 | - If you add new features, please write corresponding tests. 77 | - If you fix bugs, please add tests to prevent them from reoccurring. 78 | 79 | ### 5. Commit Changes 80 | 81 | Before committing your changes, make sure you have properly formatted and linted the code: 82 | 83 | ```bash 84 | pnpm lint 85 | pnpm format 86 | ``` 87 | 88 | Then commit your changes: 89 | 90 | ```bash 91 | git add . 92 | git commit -m "Clear and descriptive commit message" 93 | ``` 94 | 95 | ### 6. Sync Your Branch 96 | 97 | Before you submit your changes, make sure your branch is up to date: 98 | 99 | ```bash 100 | git fetch upstream 101 | git rebase upstream/main 102 | ``` 103 | 104 | ### 7. Push Your Branch 105 | 106 | Push your branch to your own repository: 107 | 108 | ```bash 109 | git push origin feature/your-branch-name 110 | ``` 111 | 112 | ### 8. Create a Pull Request 113 | 114 | On GitHub, navigate to your forked repository and click the "Compare & pull request" button. Make sure to describe your changes in detail. 115 | 116 | ## Code Review 117 | 118 | All Pull Requests will be reviewed. Please keep the following points in mind: 119 | 120 | - Is your code clear and easy to understand? 121 | - Have you followed the project's code style and standards? 122 | - Have you added appropriate tests? 123 | - Are your changes compatible with the existing code? 124 | 125 | ## Frequently Asked Questions 126 | 127 | ### How to Report a Bug? 128 | 129 | If you find a bug, please create an issue on GitHub and describe the bug and the steps to reproduce it as detailed as possible. 130 | 131 | ### How to Request a New Feature? 132 | 133 | If you have a suggestion for a new feature, please create an issue on GitHub and describe your suggestion and its potential use in detail. 134 | 135 | ## Contact Us 136 | 137 | If you have any questions or need help, please feel free to contact us via email at `2042204285@qq.com` or WeChat `yunmz777`, or ask on GitHub. 138 | -------------------------------------------------------------------------------- /src/api/collaborate-doc/collaborate-doc.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | // import { Cron, CronExpression } from '@nestjs/schedule'; 4 | import { Model } from 'mongoose'; 5 | import * as Y from 'yjs'; 6 | import { MongodbPersistence } from 'y-mongodb-provider'; 7 | import { v4 as uuidv4 } from 'uuid'; 8 | import { ConfigService } from '@nestjs/config'; 9 | 10 | import { CollaborateDoc } from './schema/collaborate-doc.schema'; 11 | import { CreateShareLinkDto, ShareDetailDto } from './dto/collaborate-doc.dto'; 12 | 13 | import { MongoDbUrlEnum } from '@/common/enum/config.enum'; 14 | 15 | @Injectable() 16 | export class CollaborateDocService implements OnModuleInit { 17 | public mdb: MongodbPersistence; 18 | 19 | constructor( 20 | @InjectModel(CollaborateDoc.name) private CollaborateDocModal: Model, 21 | private configService: ConfigService, 22 | ) {} 23 | 24 | async onModuleInit() { 25 | this.mdb = new MongodbPersistence(this.configService.get(MongoDbUrlEnum.MONGODB_URL), { 26 | collectionName: 'transactions', 27 | multipleCollections: false, 28 | }); 29 | } 30 | 31 | async getDocList() { 32 | return this.CollaborateDocModal.find({}).exec(); 33 | } 34 | 35 | async createDoc(docName: string) { 36 | const doc = new Y.Doc(); 37 | 38 | const ytext = doc.getText('monaco'); 39 | ytext.insert(0, 'console.log("hello world")'); 40 | 41 | const initialState = Y.encodeStateAsUpdate(doc); 42 | 43 | const newDoc = new this.CollaborateDocModal({ 44 | docName, 45 | state: Buffer.from(initialState), 46 | }); 47 | 48 | const data = await newDoc.save(); 49 | 50 | return { 51 | data, 52 | }; 53 | } 54 | 55 | async getDoc(recordId: string) { 56 | const doc = await this.CollaborateDocModal.findOne({ _id: recordId }); 57 | 58 | return doc; 59 | } 60 | 61 | async storeUpdate(recordId: string, update: Uint8Array): Promise { 62 | try { 63 | const existingDoc = await this.CollaborateDocModal.findOne({ _id: recordId }).exec(); 64 | 65 | if (existingDoc) { 66 | const ydoc = new Y.Doc(); 67 | 68 | const existingState = new Uint8Array(existingDoc.state); 69 | Y.applyUpdate(ydoc, existingState); 70 | Y.applyUpdate(ydoc, update); 71 | 72 | const newState = Buffer.from(Y.encodeStateAsUpdate(ydoc)); 73 | 74 | console.log('update ydoc text ', ydoc.getText('monaco').toString()); 75 | 76 | await this.CollaborateDocModal.updateOne( 77 | { _id: recordId }, 78 | { $set: { state: newState } }, 79 | ).exec(); 80 | } else { 81 | const newDoc = new this.CollaborateDocModal({ 82 | state: update, 83 | }); 84 | await newDoc.save(); 85 | } 86 | } catch (error) { 87 | console.log('storeUpdate error', error); 88 | } 89 | } 90 | 91 | async createShareLink(shareDocDto: CreateShareLinkDto) { 92 | const { recordId, accessLevel = 'edit' } = shareDocDto; 93 | 94 | const shareId = uuidv4(); 95 | 96 | const shareLink = `/share/${shareId}`; 97 | 98 | await this.CollaborateDocModal.findByIdAndUpdate(recordId, { 99 | shareId, 100 | shareLink, 101 | accessLevel, 102 | }); 103 | 104 | return { shareLink, accessLevel }; 105 | } 106 | 107 | async shareLinkDetail(shareDetailDto: ShareDetailDto) { 108 | const { shareId } = shareDetailDto; 109 | const document = await this.CollaborateDocModal.findOne({ shareId }); 110 | 111 | if (!document) { 112 | throw new NotFoundException('share link not find'); 113 | } 114 | 115 | return { 116 | document: document.state, // 假设文档内容保存在 content 字段中 117 | accessLevel: document.accessLevel, 118 | }; 119 | } 120 | 121 | // @Cron(CronExpression.EVERY_10_MINUTES) 122 | // handleMergeUpdate() { 123 | // this.mdb.flushDocument(); 124 | // } 125 | } 126 | -------------------------------------------------------------------------------- /mongo-init.js: -------------------------------------------------------------------------------- 1 | // mongo-init.js (MongoDB Shell 语法) 2 | db = db.getSiblingDB('online'); // 确定数据库名称为 'online' 3 | 4 | const initQuestions = [ 5 | { 6 | docName: '两数之和', 7 | desc: '# 问题描述\n\n给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那两个整数,并返回他们的数组下标。\n\n你可以假设每种输入只会对应一个答案。但是,数组中同一个元素不能使用两遍。\n\n## 示例\n```python\nInput: nums = [2, 7, 11, 15], target = 9\nOutput: [0, 1]\n```', 8 | }, 9 | { 10 | docName: '两数相减', 11 | desc: '# 问题描述\n\n给定两个非负整数 num1 和 num2 表示两个数,计算这两个数的差(num1 - num2),不得使用 * / % + - 等运算符。\n\n## 示例\n```python\nInput: num1 = 10, num2 = 3\nOutput: 7\n```', 12 | }, 13 | { 14 | docName: '两数相乘', 15 | desc: '# 问题描述\n\n给定两个非负整数 num1 和 num2 表示两个数,计算这两个数的乘积,不得使用 * / % + - 等运算符。\n\n## 示例\n```python\nInput: num1 = 3, num2 = 7\nOutput: 21\n```', 16 | }, 17 | { 18 | docName: '两数相除', 19 | desc: '# 问题描述\n\n给定两个整数,被除数 dividend 和除数 divisor。将两数相除,要求不使用乘法、除法和 mod 运算符。\n\n返回被除数 dividend 除以除数 divisor 得到的商。\n\n## 示例\n```python\nInput: dividend = 10, divisor = 3\nOutput: 3\n```', 20 | }, 21 | { 22 | docName: '字符串反转', 23 | desc: '# 问题描述\n\n编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 char[] 的形式给出。\n\n不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。\n\n## 示例\n```python\nInput: ["h","e","l","l","o"]\nOutput: ["o","l","l","e","h"]\n```', 24 | }, 25 | { 26 | docName: '回文检测', 27 | desc: '# 问题描述\n\n判断一个整数是否是回文数。回文数是指正序(从左向右)和倒序(从右向左)读都是一样的整数。\n\n## 示例\n```python\nInput: 121\nOutput: true\n```', 28 | }, 29 | { 30 | docName: '数组去重', 31 | desc: '# 问题描述\n\n给定一个排序数组,你需要在 原地 删除重复出现的元素,使得每个元素只出现一次,返回移除后数组的新长度。\n\n不要使用额外的数组空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。\n\n## 示例\n```python\nInput: nums = [1,1,2]\nOutput: 2, nums = [1,2]\n```', 32 | }, 33 | { 34 | docName: '数组排序', 35 | desc: '# 问题描述\n\n给定一个整数数组 nums,原地对它们进行排序。可以使用任何一种排序算法。\n\n## 示例\n```python\nInput: nums = [5, 2, 3, 1]\nOutput: [1, 2, 3, 5]\n```', 36 | }, 37 | { 38 | docName: '链表反转', 39 | desc: '# 问题描述\n\n反转一个单链表。\n\n## 示例\n```python\nInput: 1->2->3->4->5->NULL\nOutput: 5->4->3->2->1->NULL\n```', 40 | }, 41 | { 42 | docName: '二叉树遍历', 43 | desc: '# 问题描述\n\n给定一个二叉树,返回其节点值的前序/中序/后序遍历。\n\n## 示例\n```python\nInput: [1,null,2,3]\nOutput: [1,2,3] (Preorder)\nOutput: [1,3,2] (Inorder)\nOutput: [3,2,1] (Postorder)\n```', 44 | }, 45 | { 46 | docName: '最长公共前缀', 47 | desc: '# 问题描述\n\n编写一个函数来查找字符串数组中的最长公共前缀。\n\n如果不存在公共前缀,返回空字符串 ""。\n\n## 示例\n```python\nInput: ["flower","flow","flight"]\nOutput: "fl"\n```', 48 | }, 49 | { 50 | docName: '最小公倍数', 51 | desc: '# 问题描述\n\n给定两个正整数,计算它们的最小公倍数。\n\n## 示例\n```python\nInput: a = 4, b = 6\nOutput: 12\n```', 52 | }, 53 | { 54 | docName: '最大公约数', 55 | desc: '# 问题描述\n\n给定两个正整数,计算它们的最大公约数。\n\n## 示例\n```python\nInput: a = 4, b = 6\nOutput: 2\n```', 56 | }, 57 | { 58 | docName: '斐波那契数列', 59 | desc: '# 问题描述\n\n给定 n,计算斐波那契数列的第 n 项。斐波那契数列定义如下:F(0) = 0, F(1) = 1, F(n) = F(n - 1) + F(n - 2) (n >= 2)。\n\n## 示例\n```python\nInput: 4\nOutput: 3\n```', 60 | }, 61 | { 62 | docName: '汉诺塔问题', 63 | desc: '# 问题描述\n\n汉诺塔是一个经典的数学问题,通常用递归来解决。给定三个柱子 A, B, C 和 n 个不同大小的盘子,盘子初始全部位于 A 柱上。每次只能移动一个盘子,且大盘子不能放在小盘子上面。目标是将所有盘子从 A 柱移动到 C 柱。\n\n## 示例\n```python\nInput: n = 3\nOutput: ["A -> B", "A -> C", "B -> C"]\n```', 64 | }, 65 | { 66 | docName: '八皇后问题', 67 | desc: '# 问题描述\n\n八皇后问题是将八个皇后放置在国际象棋棋盘上,使得任何一个皇后都无法直接吃掉其他的皇后。为了达到此目的,任两个皇后都不能处于同一条横行、纵行或斜线上。\n\n## 示例\n```python\nInput: 8\nOutput: [[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]\n```', 68 | }, 69 | { 70 | docName: '背包问题', 71 | desc: '# 问题描述\n\n给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。\n\n## 示例\n```python\nInput: coins = [1, 2, 5], amount = 11\nOutput: 3\n```', 72 | }, 73 | { 74 | docName: '最短路径', 75 | desc: '# 问题描述\n\n给定一个加权有向图和一个起点 s,找到从 s 到其他每个顶点的最短路径。图中可能存在负权重边,但保证不存在负权重环。\n\n## 示例\n```python\nInput: graph = [[0, 4, 0, 0, 0, 0, 0, 8, 0], [4, 0, 8, 0, 0, 0, 0, 11, 0], ...], start = 0\nOutput: [0, 4, 12, ...]\n```', 76 | }, 77 | { 78 | docName: '图的遍历', 79 | desc: '# 问题描述\n\n给定一个无向图,实现深度优先搜索(DFS)和广度优先搜索(BFS)遍历。\n\n## 示例\n```python\nInput: graph = [[1, 2], [0, 2], [0, 1, 3], [2]], start = 0\nOutput: DFS: [0, 1, 2, 3], BFS: [0, 1, 2, 3]\n```', 80 | }, 81 | { 82 | docName: '动态规划', 83 | desc: '# 问题描述\n\n动态规划是一种通过把原问题分解为相互重叠的子问题来求解复杂问题的方法。编写一个函数来解决一个特定的问题,比如找零钱问题、最长递增子序列等。\n\n## 示例\n```python\nInput: coins = [1, 2, 5], amount = 11\nOutput: 3\n```', 84 | }, 85 | ]; 86 | 87 | if (db.code_questions.countDocuments() === 0) { 88 | db.code_questions.insertMany(initQuestions); 89 | print('Inserted default documents'); 90 | } else { 91 | print('Database already initialized'); 92 | } 93 | -------------------------------------------------------------------------------- /src/common/redis/redis.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject, OnModuleDestroy } from '@nestjs/common'; 2 | import Redis, { ClientContext, Result } from 'ioredis'; 3 | 4 | import { ObjectType } from '../types'; 5 | import { isObject } from '../../utils'; 6 | 7 | @Injectable() 8 | export class RedisService implements OnModuleDestroy { 9 | constructor(@Inject('REDIS_CLIENT') private readonly redisClient: Redis) {} 10 | 11 | onModuleDestroy(): void { 12 | this.redisClient.disconnect(); 13 | } 14 | 15 | /** 16 | * @Description: 设置值到redis中 17 | * @param {string} key 18 | * @param {any} value 19 | * @return {*} 20 | */ 21 | public async set(key: string, value: unknown): Promise>; 22 | 23 | public async set( 24 | key: string, 25 | value: unknown, 26 | second: number, 27 | ): Promise>; 28 | 29 | public async set(key: string, value: any, second?: number): Promise> { 30 | value = isObject(value) ? JSON.stringify(value) : value; 31 | 32 | if (!second) { 33 | return await this.redisClient.set(key, value); 34 | } else { 35 | return await this.redisClient.set(key, value, 'EX', second); 36 | } 37 | } 38 | 39 | /** 40 | * @Description: 设置自动 +1 41 | * @param {string} key 42 | * @return {*} 43 | */ 44 | public async incr(key: string): Promise> { 45 | return await this.redisClient.incr(key); 46 | } 47 | 48 | /** 49 | * @Description: 设置获取redis缓存中的值 50 | * @param key {String} 51 | */ 52 | public async get(key: string): Promise> { 53 | try { 54 | const data = await this.redisClient.get(key); 55 | 56 | if (data) { 57 | return data; 58 | } else { 59 | return null; 60 | } 61 | } catch (e) { 62 | return await this.redisClient.get(key); 63 | } 64 | } 65 | 66 | /** 67 | * @Description: 根据key删除redis缓存数据 68 | * @param {string} key 69 | * @return {*} 70 | */ 71 | public async del(keys: string | string[]): Promise> { 72 | return Array.isArray(keys) 73 | ? await this.redisClient.del(...keys) 74 | : await this.redisClient.del(keys); 75 | } 76 | 77 | async hset(key: string, field: ObjectType): Promise> { 78 | return await this.redisClient.hset(key, field); 79 | } 80 | 81 | /** 82 | * @Description: 获取单一个值 83 | * @param {string} key 84 | * @param {string} field 85 | * @return {*} 86 | */ 87 | async hget(key: string, field: string): Promise> { 88 | return await this.redisClient.hget(key, field); 89 | } 90 | 91 | /** 92 | * @Description: 获取全部的hget的 93 | * @param {string} key 94 | * @return {*} 95 | */ 96 | async hgetall(key: string): Promise, ClientContext>> { 97 | return await this.redisClient.hgetall(key); 98 | } 99 | 100 | /** 101 | * @Description: 清空redis的缓存 102 | * @return {*} 103 | */ 104 | public async flushall(): Promise> { 105 | return await this.redisClient.flushall(); 106 | } 107 | 108 | async saveOfflineNotification(userId: string, notification: any): Promise { 109 | await this.redisClient.lpush(`offline_notifications:${userId}`, JSON.stringify(notification)); 110 | } 111 | 112 | async getOfflineNotifications(userId: string): Promise { 113 | const notifications = await this.redisClient.lrange(`offline_notifications:${userId}`, 0, -1); 114 | await this.redisClient.del(`offline_notifications:${userId}`); 115 | 116 | return notifications.map((notification) => JSON.parse(notification)); 117 | } 118 | 119 | async scanKeys(pattern: string, count: number = 100): Promise { 120 | const keys: string[] = []; 121 | let cursor = '0'; 122 | 123 | try { 124 | do { 125 | const [nextCursor, scanKeys] = await this.redisClient.scan( 126 | cursor, 127 | 'MATCH', 128 | pattern, 129 | 'COUNT', 130 | count.toString(), 131 | ); 132 | cursor = nextCursor; 133 | keys.push(...scanKeys); 134 | } while (cursor !== '0'); 135 | 136 | return keys; 137 | } catch (error) { 138 | console.error('Error scanning Redis keys:', error); 139 | } 140 | } 141 | 142 | async mget(keys: string[]): Promise<(string | null)[]> { 143 | return await this.redisClient.mget(keys); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "online-edit-server", 3 | "version": "0.0.1", 4 | "description": "", 5 | "private": true, 6 | "author": "Moment", 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "cross-env NODE_ENV=production nest build", 10 | "format": "prettier --write .", 11 | "format:ci": "prettier --list-different \"src/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "cross-env NODE_ENV=development nest start --watch --trace-warnings", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "cross-env NODE_ENV=production node dist/main", 16 | "lint": "eslint \"src/**/*.ts\" --fix", 17 | "lint:ci": "eslint \"src/**/*.ts\"", 18 | "test": "jest", 19 | "test:watch": "jest --watch", 20 | "test:cov": "jest --coverage", 21 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 22 | "test:e2e": "jest --config ./test/jest-e2e.json", 23 | "postinstall": "husky install", 24 | "commit": "git-cz" 25 | }, 26 | "dependencies": { 27 | "@fastify/multipart": "^8.3.0", 28 | "@fastify/static": "^7.0.4", 29 | "@nestjs/axios": "^3.0.2", 30 | "@nestjs/bull": "^10.1.1", 31 | "@nestjs/common": "^10.3.10", 32 | "@nestjs/config": "^3.2.3", 33 | "@nestjs/core": "^10.3.10", 34 | "@nestjs/event-emitter": "^2.0.4", 35 | "@nestjs/jwt": "^10.2.0", 36 | "@nestjs/mapped-types": "^2.0.5", 37 | "@nestjs/mongoose": "^10.0.10", 38 | "@nestjs/passport": "^10.0.3", 39 | "@nestjs/platform-fastify": "^10.3.10", 40 | "@nestjs/platform-socket.io": "^10.3.10", 41 | "@nestjs/platform-ws": "^10.4.3", 42 | "@nestjs/schedule": "^4.1.0", 43 | "@nestjs/swagger": "^7.4.0", 44 | "@nestjs/terminus": "^10.2.3", 45 | "@nestjs/websockets": "^10.3.10", 46 | "@supercharge/request-ip": "^1.2.0", 47 | "@webundsoehne/nest-fastify-file-upload": "^2.2.0", 48 | "@willsoto/nestjs-prometheus": "^6.0.1", 49 | "bcrypt": "^5.1.1", 50 | "bull": "^4.15.1", 51 | "class-transformer": "^0.5.1", 52 | "class-validator": "^0.14.1", 53 | "cross-env": "^7.0.3", 54 | "fastify": "^4.28.1", 55 | "http-status-codes": "^2.3.0", 56 | "husky": "^9.1.3", 57 | "imagekit": "^5.2.0", 58 | "ioredis": "^5.4.1", 59 | "minio": "^8.0.1", 60 | "mongoose": "^8.5.1", 61 | "nodemailer": "^6.9.14", 62 | "passport-jwt": "^4.0.1", 63 | "prom-client": "^15.1.3", 64 | "reflect-metadata": "^0.2.2", 65 | "rxjs": "^7.8.1", 66 | "sharp": "^0.33.5", 67 | "socket.io": "^4.7.5", 68 | "svg-captcha": "^1.4.0", 69 | "uuid": "^10.0.0", 70 | "winston": "^3.13.1", 71 | "winston-daily-rotate-file": "^5.0.0", 72 | "y-mongodb-provider": "^0.2.0", 73 | "y-websocket": "^2.0.4", 74 | "yjs": "^13.6.19" 75 | }, 76 | "devDependencies": { 77 | "@commitlint/cli": "^19.3.0", 78 | "@commitlint/config-conventional": "^19.2.2", 79 | "@compodoc/compodoc": "^1.1.25", 80 | "@nestjs/cli": "^10.4.2", 81 | "@nestjs/schematics": "^10.1.3", 82 | "@nestjs/testing": "^10.3.10", 83 | "@swc/cli": "^0.4.0", 84 | "@swc/core": "^1.7.2", 85 | "@types/bcrypt": "^5.0.2", 86 | "@types/jest": "^29.5.12", 87 | "@types/node": "^20.14.12", 88 | "@types/nodemailer": "^6.4.15", 89 | "@types/passport-github2": "^1.2.9", 90 | "@types/passport-jwt": "^4.0.1", 91 | "@types/supertest": "^6.0.2", 92 | "@types/uuid": "^10.0.0", 93 | "@typescript-eslint/eslint-plugin": "^7.17.0", 94 | "@typescript-eslint/parser": "^7.17.0", 95 | "commitizen": "^4.3.0", 96 | "cz-git": "^1.9.4", 97 | "eslint": "^9.7.0", 98 | "eslint-config-prettier": "^9.1.0", 99 | "eslint-plugin-import": "^2.29.1", 100 | "eslint-plugin-prettier": "^5.2.1", 101 | "jest": "^29.7.0", 102 | "lint-staged": "^15.2.7", 103 | "prettier": "^3.3.3", 104 | "source-map-support": "^0.5.21", 105 | "supertest": "^7.0.0", 106 | "ts-jest": "^29.2.3", 107 | "ts-loader": "^9.5.1", 108 | "ts-node": "^10.9.2", 109 | "tsconfig-paths": "^4.2.0", 110 | "typescript": "^5.5.4" 111 | }, 112 | "jest": { 113 | "moduleFileExtensions": [ 114 | "js", 115 | "json", 116 | "ts" 117 | ], 118 | "rootDir": "src", 119 | "testRegex": ".*\\.spec\\.ts$", 120 | "transform": { 121 | "^.+\\.(t|j)s$": "ts-jest" 122 | }, 123 | "collectCoverageFrom": [ 124 | "**/*.(t|j)s" 125 | ], 126 | "coverageDirectory": "../coverage", 127 | "testEnvironment": "node" 128 | }, 129 | "lint-staged": { 130 | "*.{js,ts,jsx,tsx}": [ 131 | "pnpm lint", 132 | "pnpm format" 133 | ] 134 | }, 135 | "config": { 136 | "commitizen": { 137 | "path": "node_modules/cz-git" 138 | } 139 | }, 140 | "engines": { 141 | "node": ">=18", 142 | "pnpm": ">=9.0.0" 143 | }, 144 | "engineStrict": true 145 | } 146 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | // @see: https://cz-git.qbenben.com/zh/guide 2 | /** @type {import('cz-git').UserConfig} */ 3 | 4 | module.exports = { 5 | // ignores: [commit => commit.includes("init")], 6 | extends: ['@commitlint/config-conventional'], 7 | rules: { 8 | 'body-leading-blank': [2, 'always'], 9 | 'footer-leading-blank': [1, 'always'], 10 | 'header-max-length': [2, 'always', 108], 11 | 'subject-empty': [2, 'never'], 12 | 'type-empty': [2, 'never'], 13 | 'subject-case': [0], 14 | 'type-enum': [ 15 | 2, 16 | 'always', 17 | [ 18 | 'feat', 19 | 'fix', 20 | 'docs', 21 | 'style', 22 | 'refactor', 23 | 'perf', 24 | 'test', 25 | 'build', 26 | 'ci', 27 | 'chore', 28 | 'revert', 29 | 'wip', 30 | 'workflow', 31 | 'types', 32 | 'release', 33 | ], 34 | ], 35 | }, 36 | prompt: { 37 | messages: { 38 | type: "Select the type of change that you're committing:", 39 | scope: 'Denote the SCOPE of this change (optional):', 40 | customScope: 'Denote the SCOPE of this change:', 41 | subject: 'Write a SHORT, IMPERATIVE tense description of the change:\n', 42 | body: 'Provide a LONGER description of the change (optional). Use "|" to break new line:\n', 43 | breaking: 'List any BREAKING CHANGES (optional). Use "|" to break new line:\n', 44 | footerPrefixsSelect: 'Select the ISSUES type of changeList by this change (optional):', 45 | customFooterPrefixs: 'Input ISSUES prefix:', 46 | footer: 'List any ISSUES by this change. E.g.: #31, #34:\n', 47 | confirmCommit: 'Are you sure you want to proceed with the commit above?', 48 | // 中文版 49 | // type: "选择你要提交的类型 :", 50 | // scope: "选择一个提交范围(可选):", 51 | // customScope: "请输入自定义的提交范围 :", 52 | // subject: "填写简短精炼的变更描述 :\n", 53 | // body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n', 54 | // breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n', 55 | // footerPrefixsSelect: "选择关联issue前缀(可选):", 56 | // customFooterPrefixs: "输入自定义issue前缀 :", 57 | // footer: "列举关联issue (可选) 例如: #31, #I3244 :\n", 58 | // confirmCommit: "是否提交或修改commit ?" 59 | }, 60 | types: [ 61 | { 62 | value: 'feat', 63 | name: 'feat: 🚀 A new feature', 64 | emoji: '🚀', 65 | }, 66 | { 67 | value: 'fix', 68 | name: 'fix: 🧩 A bug fix', 69 | emoji: '🧩', 70 | }, 71 | { 72 | value: 'docs', 73 | name: 'docs: 📚 Documentation only changes', 74 | emoji: '📚', 75 | }, 76 | { 77 | value: 'style', 78 | name: 'style: 🎨 Changes that do not affect the meaning of the code', 79 | emoji: '🎨', 80 | }, 81 | { 82 | value: 'refactor', 83 | name: 'refactor: ♻️ A code change that neither fixes a bug nor adds a feature', 84 | emoji: '♻️', 85 | }, 86 | { 87 | value: 'perf', 88 | name: 'perf: ⚡️ A code change that improves performance', 89 | emoji: '⚡️', 90 | }, 91 | { 92 | value: 'test', 93 | name: 'test: ✅ Adding missing tests or correcting existing tests', 94 | emoji: '✅', 95 | }, 96 | { 97 | value: 'build', 98 | name: 'build: 📦️ Changes that affect the build system or external dependencies', 99 | emoji: '📦️', 100 | }, 101 | { 102 | value: 'ci', 103 | name: 'ci: 🎡 Changes to our CI configuration files and scripts', 104 | emoji: '🎡', 105 | }, 106 | { 107 | value: 'chore', 108 | name: "chore: 🔨 Other changes that don't modify src or test files", 109 | emoji: '🔨', 110 | }, 111 | { 112 | value: 'revert', 113 | name: 'revert: ⏪️ Reverts a previous commit', 114 | emoji: '⏪️', 115 | }, 116 | // 中文版 117 | // { value: "特性", name: "特性: 🚀 新增功能", emoji: "🚀" }, 118 | // { value: "修复", name: "修复: 🧩 修复缺陷", emoji: "🧩" }, 119 | // { value: "文档", name: "文档: 📚 文档变更", emoji: "📚" }, 120 | // { value: "格式", name: "格式: 🎨 代码格式(不影响功能,例如空格、分号等格式修正)", emoji: "🎨" }, 121 | // { value: "重构", name: "重构: ♻️ 代码重构(不包括 bug 修复、功能新增)", emoji: "♻️" }, 122 | // { value: "性能", name: "性能: ⚡️ 性能优化", emoji: "⚡️" }, 123 | // { value: "测试", name: "测试: ✅ 添加疏漏测试或已有测试改动", emoji: "✅" }, 124 | // { value: "构建", name: "构建: 📦️ 构建流程、外部依赖变更(如升级 npm 包、修改 webpack 配置等)", emoji: "📦️" }, 125 | // { value: "集成", name: "集成: 🎡 修改 CI 配置、脚本", emoji: "🎡" }, 126 | // { value: "回退", name: "回退: ⏪️ 回滚 commit", emoji: "⏪️" }, 127 | // { value: "其他", name: "其他: 🔨 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)", emoji: "🔨" } 128 | ], 129 | useEmoji: true, 130 | themeColorCode: '', 131 | scopes: [], 132 | allowCustomScopes: true, 133 | allowEmptyScopes: true, 134 | customScopesAlign: 'bottom', 135 | customScopesAlias: 'custom', 136 | emptyScopesAlias: 'empty', 137 | upperCaseSubject: false, 138 | allowBreakingChanges: ['feat', 'fix'], 139 | breaklineNumber: 100, 140 | breaklineChar: '|', 141 | skipQuestions: [], 142 | issuePrefixs: [{ value: 'closed', name: 'closed: ISSUES has been processed' }], 143 | customIssuePrefixsAlign: 'top', 144 | emptyIssuePrefixsAlias: 'skip', 145 | customIssuePrefixsAlias: 'custom', 146 | allowCustomIssuePrefixs: true, 147 | allowEmptyIssuePrefixs: true, 148 | confirmColorize: true, 149 | maxHeaderLength: Infinity, 150 | maxSubjectLength: Infinity, 151 | minSubjectLength: 0, 152 | scopeOverrides: undefined, 153 | defaultBody: '', 154 | defaultIssues: '', 155 | defaultScope: '', 156 | defaultSubject: '', 157 | }, 158 | }; 159 | -------------------------------------------------------------------------------- /src/api/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Model, Types } from 'mongoose'; 3 | import { ObjectId } from 'mongodb'; 4 | import { InjectModel } from '@nestjs/mongoose'; 5 | import * as bcrypt from 'bcrypt'; 6 | import { EventEmitter2 } from '@nestjs/event-emitter'; 7 | import { plainToInstance } from 'class-transformer'; 8 | 9 | import { User, UserDocument } from './schema/user.schema'; 10 | import { 11 | FindUserByEmailDto, 12 | UpdateUserDto, 13 | UserDto, 14 | UserWithFriendStatusDto, 15 | createUserDto, 16 | } from './dto/user.dto'; 17 | import { 18 | CreateFriendRequestDto, 19 | FriendRequestDto, 20 | UpdateFriendRequestStatusDto, 21 | } from './dto/send-friend-request.dto'; 22 | import { FriendRequest, FriendRequestDocument } from './schema/friend-request.schema'; 23 | import { Friends, FriendsDocument } from './schema/friends.schema'; 24 | import { FriendDetailsDto } from './dto/friend'; 25 | 26 | import { FriendRequestEvent } from '@/core/events/friend-request.events'; 27 | import { RedisService } from '@/common/redis/redis.service'; 28 | import { ValidationException } from '@/core/exceptions/validation.exception'; 29 | import { ResponseDto } from '@/common/dto/response.dto'; 30 | import { SocketKeys } from '@/common/enum/socket'; 31 | import { getCurrentTimestamp } from '@/utils'; 32 | import { FriendRequestStatus } from '@/common/types'; 33 | 34 | @Injectable() 35 | export class UserService { 36 | constructor( 37 | @InjectModel(User.name) private userModel: Model, 38 | @InjectModel(FriendRequest.name) private friendRequestModel: Model, 39 | @InjectModel(Friends.name) private friendModel: Model, 40 | private readonly redisService: RedisService, 41 | private readonly eventEmitter: EventEmitter2, 42 | ) {} 43 | 44 | async getUserInfo(currentUserId: string, userId?: string): Promise { 45 | const targetUserId = userId || currentUserId; 46 | 47 | const user = await this.userModel 48 | .findOne({ _id: targetUserId }) 49 | .select('-password') 50 | .lean() 51 | .exec(); 52 | 53 | const isFriend = 54 | targetUserId === currentUserId 55 | ? true 56 | : await this.areUsersFriends(currentUserId, targetUserId); 57 | 58 | return { 59 | ...user, 60 | isFriend, 61 | }; 62 | } 63 | 64 | async areUsersFriends(userId1: string, userId2: string): Promise { 65 | const friendship = await this.friendModel 66 | .findOne({ 67 | $or: [ 68 | { user_id: userId1, friend_id: userId2 }, 69 | { user_id: userId2, friend_id: userId1 }, 70 | ], 71 | }) 72 | .exec(); 73 | 74 | return !!friendship; 75 | } 76 | 77 | async findUserByEmail({ email }: FindUserByEmailDto): Promise> { 78 | const data = (await this.userModel 79 | .findOne({ email }) 80 | .select('-password') 81 | .lean() 82 | .exec()) as ResponseDto; 83 | 84 | if (data) { 85 | return data; 86 | } 87 | 88 | return null; 89 | } 90 | 91 | async createUserByEmail(data: createUserDto, isLoginType?: boolean) { 92 | const { email, code, password, confirm_password } = data; 93 | 94 | if (password !== confirm_password) { 95 | throw new ValidationException('Passwords do not match'); 96 | } 97 | 98 | // 使用 bcrypt 加密密码 99 | const saltRounds = 10; 100 | const passwordHash = await bcrypt.hash(password, saltRounds); 101 | 102 | if (isLoginType) { 103 | return await this.createAndSaveUser(email, passwordHash, 'moment'); 104 | } 105 | 106 | const uniqueId = await this.redisService.get(email); 107 | 108 | if (uniqueId !== code) { 109 | throw new ValidationException('Verification code is incorrect'); 110 | } 111 | 112 | const existingUser = await this.userModel.findOne({ email }).lean().exec(); 113 | 114 | if (!existingUser) { 115 | return await this.createAndSaveUser(email, passwordHash, 'moment'); 116 | } 117 | } 118 | 119 | private async createAndSaveUser(email: string, password: string, username: string) { 120 | const user = new this.userModel({ 121 | email, 122 | password, 123 | username, 124 | }); 125 | 126 | await user.save(); 127 | 128 | user._id = new ObjectId(user._id); 129 | 130 | return { 131 | _id: new ObjectId(user._id), 132 | username: user.username, 133 | email: user.email, 134 | }; 135 | } 136 | 137 | // 发送好友申请 138 | async sendFriendRequest( 139 | senderId: string, 140 | createFriendRequestDto: CreateFriendRequestDto, 141 | ): Promise> { 142 | const { receiverId } = createFriendRequestDto; 143 | 144 | const senderExists = await this.userModel.exists({ _id: receiverId }); 145 | 146 | if (!senderExists) { 147 | throw new ValidationException('申请的用户不存在'); 148 | } 149 | 150 | const existingFriendship = await this.friendModel 151 | .findOne({ user_id: senderId, friend_id: receiverId }) 152 | .exec(); 153 | 154 | if (existingFriendship) { 155 | throw new ValidationException('You are already friends with this user'); 156 | } 157 | 158 | const existingRequest = await this.friendRequestModel 159 | .findOne({ 160 | senderId, 161 | receiverId, 162 | }) 163 | .exec(); 164 | 165 | if (existingRequest) { 166 | existingRequest.description = createFriendRequestDto.description; 167 | existingRequest.createdAt = getCurrentTimestamp(); 168 | await existingRequest.save(); 169 | 170 | this.eventEmitter.emit( 171 | SocketKeys.FRIEND_REQUEST_UPDATED, 172 | new FriendRequestEvent({ 173 | senderId: new Types.ObjectId(senderId), 174 | receiverId: new Types.ObjectId(receiverId), 175 | description: '请求添加好友', 176 | }), 177 | ); 178 | } else { 179 | const friendRequest = new this.friendRequestModel({ 180 | senderId: senderId, 181 | ...createFriendRequestDto, 182 | }); 183 | await friendRequest.save(); 184 | 185 | this.eventEmitter.emit( 186 | SocketKeys.FRIEND_REQUEST_CREATED, 187 | new FriendRequestEvent({ 188 | senderId: new Types.ObjectId(senderId), 189 | receiverId: new Types.ObjectId(receiverId), 190 | description: '请求添加好友', 191 | }), 192 | ); 193 | } 194 | 195 | return; 196 | } 197 | 198 | async getFriendRequests(userId: string): Promise> { 199 | const data = await this.friendRequestModel 200 | .find({ $or: [{ senderId: userId }, { receiverId: userId }] }) 201 | .select('-__v') 202 | .lean() 203 | .exec(); 204 | 205 | const result = plainToInstance(FriendRequestDto, data, { 206 | enableImplicitConversion: true, 207 | }); 208 | 209 | return { data: result }; 210 | } 211 | 212 | async getFriendsList(userId: string): Promise { 213 | const friends = await this.friendModel 214 | .find({ 215 | $or: [{ user_id: userId }, { friend_id: userId }], 216 | }) 217 | .select('user_id friend_id createdAt userRemark friendRemark') 218 | .lean() 219 | .exec(); 220 | 221 | const friendIds = friends.map((friend) => 222 | friend.user_id.toString() === userId ? friend.friend_id : friend.user_id, 223 | ); 224 | 225 | const users = await this.userModel 226 | .find({ _id: { $in: friendIds } }) 227 | .select('email username avatar') 228 | .lean() 229 | .exec(); 230 | 231 | const userMap = new Map(users.map((user) => [user._id.toString(), user])); 232 | 233 | const result = friends 234 | .map((friend) => { 235 | const friendId = friend.user_id.toString() === userId ? friend.friend_id : friend.user_id; 236 | const remark = 237 | friend.user_id.toString() === userId ? friend.friendRemark : friend.userRemark; 238 | 239 | const user = userMap.get(friendId.toString()); 240 | 241 | if (user) { 242 | return { 243 | id: friend._id.toString(), 244 | friendId: friendId.toString(), 245 | friendEmail: user.email, 246 | friendUsername: user.username, 247 | friendRemark: remark, 248 | createdAt: friend.createdAt, 249 | avatar: user.avatar, 250 | }; 251 | } 252 | 253 | return null; 254 | }) 255 | .filter((friend) => friend !== null); 256 | 257 | return result as FriendDetailsDto[]; 258 | } 259 | 260 | async updateFriendRequestStatus( 261 | requestId: string, 262 | updateFriendRequestDto: UpdateFriendRequestStatusDto, 263 | ): Promise> { 264 | const friendRequest = await this.friendRequestModel.findOne({ senderId: requestId }).exec(); 265 | 266 | if (!friendRequest) { 267 | throw new ValidationException('Friend request not found'); 268 | } 269 | 270 | if (friendRequest.status !== FriendRequestStatus.PENDING) { 271 | throw new ValidationException('Friend request is not pending'); 272 | } 273 | 274 | this.eventEmitter.emit( 275 | SocketKeys.FRIEND_REQUEST_UPDATED, 276 | new FriendRequestEvent({ 277 | senderId: new Types.ObjectId(requestId), 278 | receiverId: friendRequest.id, 279 | description: '我已经通过好友了,我们可以开始交流了', 280 | }), 281 | ); 282 | 283 | return; 284 | } 285 | 286 | async updateUserInfo(userId: string, updateUserDto: UpdateUserDto): Promise { 287 | const updatedUser = await this.userModel 288 | .findByIdAndUpdate(userId, { $set: updateUserDto }, { new: true, runValidators: true }) 289 | .exec(); 290 | 291 | if (!updatedUser) { 292 | throw new ValidationException('User not found'); 293 | } 294 | 295 | return; 296 | } 297 | } 298 | --------------------------------------------------------------------------------