├── src ├── utils │ ├── chat.ts │ ├── date.ts │ ├── initDatabase.ts │ ├── verifyToken.ts │ ├── tools.ts │ └── spider.ts ├── modules │ ├── music │ │ ├── dto │ │ │ ├── music.dto.ts │ │ │ ├── search.dto.ts │ │ │ └── addAlbum.dto.ts │ │ ├── music.module.ts │ │ ├── music.entity.ts │ │ ├── collect.entity.ts │ │ ├── music.controller.ts │ │ └── music.service.ts │ ├── upload │ │ ├── upload.module.ts │ │ ├── upload.controller.ts │ │ └── upload.service.ts │ ├── user │ │ ├── dto │ │ │ ├── login.user.dto.ts │ │ │ └── register.user.dto.ts │ │ ├── user.module.ts │ │ ├── user.controller.ts │ │ ├── user.entity.ts │ │ └── user.service.ts │ └── chat │ │ ├── message.entity.ts │ │ ├── chat.module.ts │ │ ├── room.entity.ts │ │ ├── chat.controller.ts │ │ ├── chat.service.ts │ │ └── chat.getaway.ts ├── config │ ├── jwt.ts │ └── database.ts ├── swagger │ └── index.ts ├── interceptor │ └── transform.interceptor.ts ├── common │ └── entity │ │ └── baseEntity.ts ├── app.module.ts ├── main.ts ├── guard │ └── auth.guard.ts ├── filters │ └── http-exception.filter.ts └── constant │ └── avatar.ts ├── .npmrc ├── nest-cli.json ├── gitImgs ├── 001.jpg ├── 002.png ├── 003.jpg └── 004.jpg ├── .prettierrc ├── public └── basic │ └── wx.png ├── tsconfig.build.json ├── .env ├── index.md ├── test ├── jest-e2e.json └── app.e2e-spec.ts ├── .gitignore ├── tsconfig.json ├── .eslintrc.js ├── .vscode └── settings.json ├── README.md └── package.json /src/utils/chat.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmmirror.com -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /gitImgs/001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CooperJiang/Nine-chat-backend/HEAD/gitImgs/001.jpg -------------------------------------------------------------------------------- /gitImgs/002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CooperJiang/Nine-chat-backend/HEAD/gitImgs/002.png -------------------------------------------------------------------------------- /gitImgs/003.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CooperJiang/Nine-chat-backend/HEAD/gitImgs/003.jpg -------------------------------------------------------------------------------- /gitImgs/004.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CooperJiang/Nine-chat-backend/HEAD/gitImgs/004.jpg -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 120 5 | } -------------------------------------------------------------------------------- /public/basic/wx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CooperJiang/Nine-chat-backend/HEAD/public/basic/wx.png -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | PREFIX=/docs 3 | 4 | DB_HOST=127.0.0.1 5 | DB_PORT=3306 6 | DB_USER=root 7 | DB_PASS= 8 | DB_DATABASE=cooper-music-chat -------------------------------------------------------------------------------- /index.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

-------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import * as moment from 'moment'; 2 | 3 | export const formatDate = (dateNum: string | number): string => { 4 | return moment(dateNum).format('YYYY-MM-DD HH:mm:ss'); 5 | }; 6 | -------------------------------------------------------------------------------- /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/modules/music/dto/music.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class musicDto { 5 | @ApiProperty({ example: 175515991, description: '歌曲的mid', required: true }) 6 | @IsNotEmpty({ message: 'mid不能为空' }) 7 | mid: number; 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/upload/upload.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UploadController } from './upload.controller'; 3 | import { UploadService } from './upload.service'; 4 | 5 | @Module({ 6 | controllers: [UploadController], 7 | providers: [UploadService] 8 | }) 9 | export class UploadModule {} 10 | -------------------------------------------------------------------------------- /.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 | *.sublime-workspace -------------------------------------------------------------------------------- /src/modules/music/dto/search.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class searchDto { 4 | @ApiProperty({ example: '孤城', description: '关键词', required: false }) 5 | keyword: string; 6 | 7 | @ApiProperty({ example: 1, description: '页码', required: false }) 8 | page: number; 9 | 10 | @ApiProperty({ example: 10, description: '单页数量', required: false }) 11 | pagesize: number; 12 | } 13 | -------------------------------------------------------------------------------- /src/config/jwt.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 权限部分暂时没做扩展 只有基础权限按接口加请求方式来区分 接口多了可以加中间件与装饰器 3 | */ 4 | 5 | export const secret = 'chat-cooper'; 6 | export const expiresIn = '7d'; 7 | export const whiteList = [ 8 | '/api/user/login', 9 | '/api/user/register', 10 | '/api/upload/file', 11 | ]; 12 | 13 | /** 14 | * post 请求的白名单,不限制身份的 15 | */ 16 | export const postWhiteList = [ 17 | '/api/comment/set', 18 | '/api/user/update', 19 | '/api/chat/history', 20 | ]; 21 | -------------------------------------------------------------------------------- /src/swagger/index.ts: -------------------------------------------------------------------------------- 1 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 2 | 3 | const swaggerOptions = new DocumentBuilder() 4 | .setTitle('snine blog api document') 5 | .setDescription('about snine blog api docs') 6 | .setVersion('1.0.0') 7 | .addBearerAuth() 8 | .build(); 9 | 10 | export function createSwagger(app) { 11 | const document = SwaggerModule.createDocument(app, swaggerOptions); 12 | SwaggerModule.setup('/docs', app, document); 13 | } 14 | -------------------------------------------------------------------------------- /src/config/database.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import { ConnectionOptions } from 'typeorm'; 3 | 4 | const databaseConfig: ConnectionOptions = { 5 | type: 'mysql', 6 | port: 3306, 7 | host: process.env.DB_HOST, 8 | username: process.env.DB_USER, 9 | password: process.env.DB_PASS, 10 | database: process.env.DB_DATABASE, 11 | entities: [join(__dirname, '../', '**/**.entity{.ts,.js}')], 12 | logging: false, 13 | synchronize: true, 14 | }; 15 | 16 | export default databaseConfig; 17 | -------------------------------------------------------------------------------- /src/modules/music/music.module.ts: -------------------------------------------------------------------------------- 1 | import { CollectEntity } from './collect.entity'; 2 | import { MusicEntity } from './music.entity'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { Module } from '@nestjs/common'; 5 | import { MusicController } from './music.controller'; 6 | import { MusicService } from './music.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([MusicEntity, CollectEntity])], 10 | controllers: [MusicController], 11 | providers: [MusicService], 12 | }) 13 | export class MusicModule {} 14 | -------------------------------------------------------------------------------- /src/modules/music/dto/addAlbum.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class addAlbumDto { 4 | @ApiProperty({ 5 | example: '355994788', 6 | description: '专辑ID 点开酷我的专辑 最后面/的数字', 7 | required: true, 8 | }) 9 | albumId: number; 10 | 11 | @ApiProperty({ 12 | example: 1, 13 | description: '添加的页数 默认为一页 具体看专辑的页数', 14 | required: false, 15 | }) 16 | page?: number; 17 | 18 | @ApiProperty({ 19 | example: 20, 20 | description: '添加的数量', 21 | required: false, 22 | }) 23 | size?: number; 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/upload/upload.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Post, 4 | UploadedFile, 5 | UseInterceptors, 6 | } from '@nestjs/common'; 7 | import { UploadService } from './upload.service'; 8 | import { FileInterceptor } from '@nestjs/platform-express'; 9 | 10 | @Controller('upload') 11 | export class UploadController { 12 | constructor(private readonly uploadService: UploadService) {} 13 | 14 | @Post('file') 15 | @UseInterceptors(FileInterceptor('file')) 16 | async uploadFile(@UploadedFile() file) { 17 | return this.uploadService.uploadFile(file); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/user/dto/login.user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, MinLength, MaxLength } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class UserLoginDto { 5 | @ApiProperty({ example: 'admin', description: '用户名', required: true }) 6 | @IsNotEmpty({ message: '用户名不能为空' }) 7 | user_name: string; 8 | 9 | @ApiProperty({ example: '123456', description: '密码', required: true }) 10 | @IsNotEmpty({ message: '密码不能为空' }) 11 | @MinLength(6, { message: '密码长度最低为6位' }) 12 | @MaxLength(30, { message: '密码长度最多为30位' }) 13 | user_password: string; 14 | } 15 | -------------------------------------------------------------------------------- /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": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/music/music.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity } from 'typeorm'; 2 | import { BaseEntity } from 'src/common/entity/baseEntity'; 3 | 4 | @Entity({ name: 'tb_music' }) 5 | export class MusicEntity extends BaseEntity { 6 | @Column({ length: 300, comment: '歌曲专辑' }) 7 | music_album: string; 8 | 9 | @Column({ length: 300, comment: '歌曲名称' }) 10 | music_name: string; 11 | 12 | @Column({ unique: true, comment: '歌曲mid' }) 13 | music_mid: number; 14 | 15 | @Column({ comment: '歌曲时长' }) 16 | music_duration: number; 17 | 18 | @Column({ length: 300, comment: '歌曲作者' }) 19 | music_singer: string; 20 | 21 | @Column({ comment: '是否推荐到热门歌曲 1:是 -1:不是', default: 0 }) 22 | is_recommend: number; 23 | } 24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /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 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/modules/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { UserEntity } from './user.entity'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Module } from '@nestjs/common'; 4 | import { UserController } from './user.controller'; 5 | import { UserService } from './user.service'; 6 | import { JwtModule } from '@nestjs/jwt'; 7 | import { expiresIn, secret } from 'src/config/jwt'; 8 | import { PassportModule } from '@nestjs/passport'; 9 | 10 | @Module({ 11 | imports: [ 12 | PassportModule, 13 | TypeOrmModule.forFeature([UserEntity]), 14 | JwtModule.register({ 15 | secret, 16 | signOptions: { expiresIn }, 17 | }), 18 | ], 19 | controllers: [UserController], 20 | providers: [UserService], 21 | }) 22 | export class UserModule {} 23 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.formatOnSave": false, 4 | "breadcrumbs.enabled": true, 5 | "editor.renderControlCharacters": true, 6 | "vetur.format.defaultFormatter.html": "prettyhtml", 7 | "vetur.format.defaultFormatterOptions": { 8 | "js-beautify-html": { 9 | "wrap_attributes": "auto" 10 | }, 11 | "prettyhtml": { 12 | "printWidth": 200, 13 | "singleQuote": false, 14 | "wrapAttributes": false, 15 | "sortAttributes": false 16 | }, 17 | "prettier": { 18 | "semi": false, 19 | "singleQuote": true 20 | } 21 | }, 22 | "editor.codeActionsOnSave": { 23 | "source.fixAll.eslint": "explicit" 24 | }, 25 | "cSpell.words": [ 26 | "reqid" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/interceptor/transform.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | CallHandler, 5 | ExecutionContext, 6 | } from '@nestjs/common'; 7 | import { map } from 'rxjs/operators'; 8 | import { Observable } from 'rxjs'; 9 | interface Response { 10 | data: T; 11 | } 12 | @Injectable() 13 | export class TransformInterceptor 14 | implements NestInterceptor> 15 | { 16 | intercept( 17 | context: ExecutionContext, 18 | next: CallHandler, 19 | ): Observable> { 20 | return next.handle().pipe( 21 | map((data) => { 22 | return { 23 | data, 24 | code: 200, 25 | success: true, 26 | message: '请求成功', 27 | }; 28 | }), 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/chat/message.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity } from 'typeorm'; 2 | import { BaseEntity } from 'src/common/entity/baseEntity'; 3 | 4 | @Entity({ name: 'tb_message' }) 5 | export class MessageEntity extends BaseEntity { 6 | @Column({ comment: '用户id' }) 7 | user_id: number; 8 | 9 | @Column({ comment: '房间ID' }) 10 | room_id: number; 11 | 12 | @Column('text') 13 | message_content: string; 14 | 15 | @Column({ length: 64, comment: '消息类型' }) 16 | message_type: string; 17 | 18 | @Column({ nullable: true, comment: '引用消息人的id[引用了谁的消息]' }) 19 | quote_user_id: number; 20 | 21 | @Column({ nullable: true, comment: '引用的消息ID' }) 22 | quote_message_id: number; 23 | 24 | @Column({ comment: '消息状态: 1: 正常 -1: 已撤回', default: 1 }) 25 | message_status: number; 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/chat/chat.module.ts: -------------------------------------------------------------------------------- 1 | import { RoomEntity } from './room.entity'; 2 | import { MusicEntity } from './../music/music.entity'; 3 | import { MessageEntity } from './message.entity'; 4 | import { UserEntity } from './../user/user.entity'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { WsChatGateway } from './chat.getaway'; 7 | import { Module } from '@nestjs/common'; 8 | import { ChatController } from './chat.controller'; 9 | import { ChatService } from './chat.service'; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([ 14 | UserEntity, 15 | MessageEntity, 16 | MusicEntity, 17 | RoomEntity, 18 | ]), 19 | ], 20 | controllers: [ChatController], 21 | providers: [ChatService, WsChatGateway], 22 | }) 23 | export class ChatModule {} 24 | -------------------------------------------------------------------------------- /src/common/entity/baseEntity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | CreateDateColumn, 5 | UpdateDateColumn, 6 | DeleteDateColumn, 7 | } from 'typeorm'; 8 | 9 | @Entity() 10 | export class BaseEntity { 11 | @PrimaryGeneratedColumn() 12 | id: number; 13 | 14 | @CreateDateColumn({ 15 | type: 'datetime', 16 | length: 0, 17 | nullable: false, 18 | name: 'created_at', 19 | comment: '创建时间', 20 | }) 21 | createdAt: Date; 22 | 23 | @UpdateDateColumn({ 24 | type: 'datetime', 25 | length: 0, 26 | nullable: false, 27 | name: 'updated_at', 28 | comment: '更新时间', 29 | }) 30 | updatedAt: Date; 31 | 32 | @DeleteDateColumn({ 33 | type: 'datetime', 34 | length: 0, 35 | nullable: false, 36 | name: 'deleted_at', 37 | comment: '删除时间', 38 | }) 39 | deletedAt: Date; 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/music/collect.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity } from 'typeorm'; 2 | import { BaseEntity } from 'src/common/entity/baseEntity'; 3 | 4 | @Entity({ name: 'tb_collect' }) 5 | export class CollectEntity extends BaseEntity { 6 | @Column({ comment: '用户ID' }) 7 | user_id: number; 8 | 9 | @Column({ comment: '歌曲mid' }) 10 | music_mid: number; 11 | 12 | @Column({ length: 255, comment: '歌曲封面图' }) 13 | music_cover: string; 14 | 15 | @Column({ length: 255, comment: '歌曲专辑大图' }) 16 | music_albumpic: string; 17 | 18 | @Column({ length: 255, comment: '歌曲作者' }) 19 | music_singer: string; 20 | 21 | @Column({ length: 255, comment: '歌曲专辑' }) 22 | music_album: string; 23 | 24 | @Column({ length: 255, comment: '歌曲名字' }) 25 | music_name: string; 26 | 27 | @Column({ comment: '软删除 1:正常 -1:已删除', default: 1 }) 28 | is_delete: number; 29 | } 30 | -------------------------------------------------------------------------------- /src/modules/chat/room.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity } from 'typeorm'; 2 | import { BaseEntity } from 'src/common/entity/baseEntity'; 3 | 4 | @Entity({ name: 'tb_room' }) 5 | export class RoomEntity extends BaseEntity { 6 | @Column({ unique: true, comment: '房间创建人id' }) 7 | room_user_id: number; 8 | 9 | @Column({ unique: true, comment: '房间ID' }) 10 | room_id: number; 11 | 12 | @Column({ length: 255, nullable: true, comment: '房间logo' }) 13 | room_logo: string; 14 | 15 | @Column({ length: 20, comment: '房间名称' }) 16 | room_name: string; 17 | 18 | @Column({ default: 1, comment: '房间是否需要密码 1:公开 2:加密' }) 19 | room_need_password: number; 20 | 21 | @Column({ length: 255, nullable: true, comment: '房间密码' }) 22 | room_password: string; 23 | 24 | @Column({ length: 512, default: '房间空空如也呢', comment: '房间公告' }) 25 | room_notice: string; 26 | 27 | @Column({ length: 255, nullable: true, comment: '房间背景图片' }) 28 | room_bg_img: string; 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/initDatabase.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import * as mysql from 'mysql2/promise'; 3 | 4 | export function initDatabase() { 5 | try { 6 | mysql 7 | .createConnection({ 8 | host: process.env.DB_HOST, 9 | user: process.env.DB_USER, 10 | password: process.env.DB_PASS, 11 | port: parseInt(process.env.DB_PORT), 12 | }) 13 | .then(async (conn) => { 14 | const [rows] = await conn.execute( 15 | `SHOW DATABASES LIKE '${process.env.DB_DATABASE}'`, 16 | ); 17 | if (Array.isArray(rows) && rows.length === 0) { 18 | await conn.execute(`CREATE DATABASE \`${process.env.DB_DATABASE}\``); 19 | Logger.log(` 数据库自动创建成功 ${process.env.DB_DATABASE}`); 20 | } 21 | await conn.end(); 22 | }) 23 | .catch((err) => { 24 | console.log( 25 | '自动创建数据库失败, 请确认你使用的是root权限 否则请手动创建数据库: ', 26 | err, 27 | ); 28 | }); 29 | } catch (error) { 30 | console.log('auto create database err : ', error); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from 'nestjs-config'; 3 | import { resolve } from 'path'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { ChatModule } from './modules/chat/chat.module'; 6 | import { MusicModule } from './modules/music/music.module'; 7 | import { UserModule } from './modules/user/user.module'; 8 | import { ServeStaticModule } from '@nestjs/serve-static'; 9 | import { join } from 'path'; 10 | import { UploadModule } from './modules/upload/upload.module'; 11 | 12 | @Module({ 13 | imports: [ 14 | ServeStaticModule.forRoot({ 15 | rootPath: join(__dirname, '..', 'public'), 16 | }), 17 | ConfigModule.load(resolve(__dirname, 'config', '**/!(*.d).{ts,js}')), 18 | TypeOrmModule.forRootAsync({ 19 | useFactory: (config: ConfigService) => config.get('database'), 20 | inject: [ConfigService], 21 | }), 22 | ChatModule, 23 | MusicModule, 24 | UserModule, 25 | UploadModule, 26 | ], 27 | controllers: [], 28 | providers: [], 29 | }) 30 | export class AppModule {} 31 | -------------------------------------------------------------------------------- /src/modules/chat/chat.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Body, Get, Query, Request } from '@nestjs/common'; 2 | import { ApiTags } from '@nestjs/swagger'; 3 | import { ChatService } from './chat.service'; 4 | // import { emoticonSearchDto } from './dto/search.dto'; 5 | 6 | @ApiTags('Chat') 7 | @Controller('chat') 8 | export class ChatController { 9 | constructor(private readonly ChatService: ChatService) {} 10 | 11 | @Post('/history') 12 | history(@Body() params) { 13 | return this.ChatService.history(params); 14 | } 15 | 16 | @Get('/emoticon') 17 | emoticon(@Query() params) { 18 | return this.ChatService.emoticon(params); 19 | } 20 | 21 | @Post('/createRoom') 22 | createRoom(@Body() params, @Request() req) { 23 | return this.ChatService.createRoom(params, req); 24 | } 25 | 26 | @Get('/roomInfo') 27 | roomInfo(@Query() params) { 28 | return this.ChatService.roomInfo(params); 29 | } 30 | 31 | @Post('/updateRoomInfo') 32 | updateRoomInfo(@Body() params, @Request() req) { 33 | return this.ChatService.updateRoomInfo(params, req.payload); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { ApiTags } from '@nestjs/swagger'; 2 | import { UserService } from './user.service'; 3 | import { Body, Controller, Post, Request, Get, Query } from '@nestjs/common'; 4 | import { UserRegisterDto } from './dto/register.user.dto'; 5 | import { UserLoginDto } from './dto/login.user.dto'; 6 | 7 | @Controller('/user') 8 | @ApiTags('User') 9 | export class UserController { 10 | constructor(private readonly userService: UserService) {} 11 | 12 | @Post('/register') 13 | register(@Body() params: UserRegisterDto) { 14 | return this.userService.register(params); 15 | } 16 | 17 | @Post('/login') 18 | login(@Body() params: UserLoginDto) { 19 | return this.userService.login(params); 20 | } 21 | 22 | @Get('/getInfo') 23 | queryInfo(@Request() req) { 24 | return this.userService.getInfo(req.payload); 25 | } 26 | 27 | @Get('/query') 28 | query(@Query() params) { 29 | return this.userService.query(params); 30 | } 31 | 32 | @Post('/update') 33 | update(@Request() req, @Body() params) { 34 | return this.userService.update(req.payload, params); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/verifyToken.ts: -------------------------------------------------------------------------------- 1 | import * as jwt from 'jsonwebtoken'; 2 | import { HttpException, HttpStatus } from '@nestjs/common'; 3 | import { secret as key } from 'src/config/jwt'; 4 | 5 | /** 6 | * @desc 解析token 7 | * @param token 8 | * @param secret 9 | * @returns 10 | */ 11 | export function verifyToken(token, secret: string = key): Promise { 12 | return new Promise((resolve) => { 13 | jwt.verify(token, secret, (error, payload) => { 14 | if (error) { 15 | // throw new HttpException('身份验证失败', HttpStatus.UNAUTHORIZED); 16 | resolve({ user_id: -1 }); 17 | } else { 18 | resolve(payload); 19 | } 20 | }); 21 | }); 22 | } 23 | 24 | /** 25 | * @desc 解析token 26 | * @param token 27 | * @param secret 28 | * @returns 29 | */ 30 | export function verifyPublicToken( 31 | token: string, 32 | secret: string = key, 33 | ): Promise { 34 | return new Promise((resolve) => { 35 | jwt.verify(token, secret, (error, payload) => { 36 | if (error) { 37 | throw new HttpException('身份验证失败', HttpStatus.UNAUTHORIZED); 38 | } else { 39 | resolve(payload); 40 | } 41 | }); 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/user/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity } from 'typeorm'; 2 | import { BaseEntity } from 'src/common/entity/baseEntity'; 3 | 4 | @Entity({ name: 'tb_user' }) 5 | export class UserEntity extends BaseEntity { 6 | @Column({ length: 12, comment: '用户名' }) 7 | user_name: string; 8 | 9 | @Column({ length: 12, comment: '用户昵称' }) 10 | user_nick: string; 11 | 12 | @Column({ length: 1000, comment: '用户密码' }) 13 | user_password: string; 14 | 15 | @Column({ default: 1, comment: '用户状态' }) 16 | user_status: number; 17 | 18 | @Column({ default: 1, comment: '用户性别' }) 19 | user_sex: number; 20 | 21 | @Column({ length: 64, unique: true, comment: '用户邮箱' }) 22 | user_email: string; 23 | 24 | @Column({ length: 600, nullable: true, comment: '用户头像' }) 25 | user_avatar: string; 26 | 27 | @Column({ length: 10, default: 'viewer', comment: '用户权限' }) 28 | user_role: string; 29 | 30 | @Column({ length: 255, nullable: true, comment: '用户个人聊天室背景图' }) 31 | user_room_bg: string; 32 | 33 | @Column({ length: 255, nullable: true, comment: '用户个人创建的房间Id' }) 34 | user_room_id: string; 35 | 36 | @Column({ length: 255, default: '每个人都有签名、我希望你也有...' }) 37 | user_sign: string; 38 | } 39 | -------------------------------------------------------------------------------- /src/modules/music/music.controller.ts: -------------------------------------------------------------------------------- 1 | import { MusicService } from './music.service'; 2 | import { Controller, Get, Query, Request, Post, Body } from '@nestjs/common'; 3 | import { ApiTags } from '@nestjs/swagger'; 4 | import { searchDto } from './dto/search.dto'; 5 | import { addAlbumDto } from './dto/addAlbum.dto'; 6 | 7 | @ApiTags('Music') 8 | @Controller('music') 9 | export class MusicController { 10 | constructor(private readonly MusicService: MusicService) {} 11 | 12 | @Post('/getAlbumList') 13 | getAlbumList(@Body() params: addAlbumDto) { 14 | return this.MusicService.getAlbumList(params); 15 | } 16 | 17 | @Get('/search') 18 | search(@Query() params: searchDto) { 19 | return this.MusicService.search(params); 20 | } 21 | 22 | @Post('/collectMusic') 23 | collectMusic(@Request() req, @Body() params) { 24 | return this.MusicService.collectMusic(req.payload, params); 25 | } 26 | 27 | @Get('/collectList') 28 | collectList(@Request() req, @Query() params) { 29 | return this.MusicService.collectList(req.payload, params); 30 | } 31 | 32 | @Get('/hot') 33 | hot(@Request() req, @Query() params) { 34 | return this.MusicService.hot(params); 35 | } 36 | 37 | @Post('/removeCollect') 38 | removeCollect(@Request() req, @Body() params) { 39 | return this.MusicService.removeCollect(req.payload, params); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as Dotenv from 'dotenv'; 2 | Dotenv.config({ path: '.env' }); 3 | import { NestFactory } from '@nestjs/core'; 4 | import { AppModule } from './app.module'; 5 | import { Logger, ValidationPipe } from '@nestjs/common'; 6 | import { createSwagger } from './swagger/index'; 7 | import { IoAdapter } from '@nestjs/platform-socket.io'; 8 | import { HttpExceptionFilter } from './filters/http-exception.filter'; 9 | import { TransformInterceptor } from './interceptor/transform.interceptor'; 10 | import { AuthGuard } from './guard/auth.guard'; 11 | import { initDatabase } from './utils/initDatabase'; 12 | 13 | async function bootstrap() { 14 | await initDatabase(); 15 | const app = await NestFactory.create(AppModule); 16 | app.useWebSocketAdapter(new IoAdapter(app)); 17 | app.useGlobalFilters(new HttpExceptionFilter()); 18 | app.useGlobalGuards(new AuthGuard()); 19 | app.useGlobalPipes(new ValidationPipe()); 20 | app.useGlobalInterceptors(new TransformInterceptor()); 21 | app.setGlobalPrefix('/api'); 22 | createSwagger(app); 23 | app.enableCors(); 24 | const port = process.env.PORT || 3000; 25 | await app.listen(port, () => { 26 | Logger.log(`API服务已经启动,服务请访问:http://localhost:${port}/api`); 27 | Logger.log(`WebSocket服务已经启动,服务请访问:http://localhost:${port}`); 28 | Logger.log(`swagger已经启动,服务请访问:http://localhost:${port}/docs`); 29 | Logger.log( 30 | `可将前端项目打包结果放至public下直接访问服务:http://localhost:${port}`, 31 | ); 32 | }); 33 | } 34 | bootstrap(); 35 | -------------------------------------------------------------------------------- /src/modules/user/dto/register.user.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsNotEmpty, 3 | MinLength, 4 | MaxLength, 5 | IsEmail, 6 | IsOptional, 7 | IsEnum, 8 | } from 'class-validator'; 9 | import { ApiProperty } from '@nestjs/swagger'; 10 | import { Type } from 'class-transformer'; 11 | 12 | export class UserRegisterDto { 13 | @ApiProperty({ example: 'admin', description: '用户名' }) 14 | @IsNotEmpty({ message: '用户名不能为空' }) 15 | user_name: string; 16 | 17 | @ApiProperty({ example: '小九', description: '用户昵称' }) 18 | @IsNotEmpty({ message: '用户昵称不能为空' }) 19 | @MaxLength(8, { message: '用户昵称长度最多为8位' }) 20 | user_nick: string; 21 | 22 | @ApiProperty({ example: '123456', description: '密码' }) 23 | @IsNotEmpty({ message: '密码不能为空' }) 24 | @MinLength(6, { message: '密码长度最低为6位' }) 25 | @MaxLength(30, { message: '密码长度最多为30位' }) 26 | user_password: string; 27 | 28 | @ApiProperty({ 29 | example: '每个人都有签名、我希望你也有', 30 | description: '个人签名', 31 | }) 32 | user_sign: string; 33 | 34 | @ApiProperty({ example: '927898639@qq.com', description: '邮箱' }) 35 | @IsEmail({}, { message: '请填写正确格式的邮箱' }) 36 | user_email: string; 37 | 38 | @ApiProperty({ 39 | example: 'https://img2.baidu.com/it/u=2285567582,1185119578&fm=26&fmt=auto', 40 | description: '头像', 41 | required: false, 42 | }) 43 | user_avatar: string; 44 | 45 | @ApiProperty({ 46 | example: 1, 47 | description: '账号状态', 48 | required: false, 49 | enum: [1, 2], 50 | }) 51 | @IsOptional() 52 | @IsEnum([1, 2], { message: 'sex只能是1或者2' }) 53 | @Type(() => Number) 54 | user_status: number; 55 | } 56 | -------------------------------------------------------------------------------- /src/guard/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | HttpException, 5 | Injectable, 6 | HttpStatus, 7 | } from '@nestjs/common'; 8 | import * as jwt from 'jsonwebtoken'; 9 | import { secret, whiteList } from 'src/config/jwt'; 10 | 11 | @Injectable() 12 | export class AuthGuard implements CanActivate { 13 | async canActivate(context: ExecutionContext): Promise { 14 | const request = context.switchToHttp().getRequest(); 15 | const { headers, path, route } = context.switchToRpc().getData(); 16 | 17 | if (whiteList.includes(path)) { 18 | return true; 19 | } 20 | 21 | const isGet = route.methods.get; 22 | const token = headers.authorization || request.headers.authorization; 23 | 24 | if (token) { 25 | const payload = await this.verifyToken(token, secret); 26 | // const { user_role } = payload; 具体业务可以根据权限再加 当前项目不需要权限验证 有token即可 27 | request.payload = payload; 28 | return true; 29 | } else { 30 | if (isGet) return true; 31 | throw new HttpException('你还没登录,请先登录', HttpStatus.UNAUTHORIZED); 32 | } 33 | } 34 | 35 | /** 36 | * @desc 全局校验token 37 | * @param token 38 | * @param secret 39 | * @returns 40 | */ 41 | private verifyToken(token: string, secret: string): Promise { 42 | return new Promise((resolve) => { 43 | jwt.verify(token, secret, (error, payload) => { 44 | if (error) { 45 | throw new HttpException('身份验证失败', HttpStatus.UNAUTHORIZED); 46 | } else { 47 | resolve(payload); 48 | } 49 | }); 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/filters/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | ExceptionFilter, 5 | HttpException, 6 | HttpStatus, 7 | Logger, 8 | } from '@nestjs/common'; 9 | import { formatDate } from 'src/utils/date'; 10 | 11 | @Catch(HttpException) 12 | export class HttpExceptionFilter implements ExceptionFilter { 13 | catch(exception: HttpException, host: ArgumentsHost) { 14 | const ctx = host.switchToHttp(); 15 | const response = ctx.getResponse(); 16 | const request = ctx.getRequest(); 17 | const {} = response; 18 | const exceptionRes: any = exception.getResponse(); 19 | 20 | /* 正常情况是个对象,为了简写可以只传入一个字符串错误即可 */ 21 | const message = 22 | exceptionRes.constructor === Object 23 | ? exceptionRes['message'] 24 | : exceptionRes; 25 | // const { message } = exceptionRes; 26 | const statusCode = exception.getStatus() || 400; 27 | /* 是数组就返回错误里的第一条即可,不是就返回字符串 */ 28 | const errorResponse = { 29 | message: Array.isArray(message) ? message[0] : message, 30 | code: statusCode, 31 | success: false, 32 | url: request.originalUrl, 33 | timestamp: new Date().toLocaleDateString(), 34 | }; 35 | const status = 36 | exception instanceof HttpException 37 | ? exception.getStatus() 38 | : HttpStatus.INTERNAL_SERVER_ERROR; 39 | 40 | Logger.error( 41 | `【${formatDate(Date.now())}】${request.method} ${request.url}`, 42 | JSON.stringify(errorResponse), 43 | 'HttpExceptionFilter', 44 | ); 45 | console.log(errorResponse, 'errorResponse'); 46 | 47 | /* 设置返回的状态码、请求头、发送错误信息 */ 48 | response.status(status); 49 | response.header('Content-Type', 'application/json; charset=utf-8'); 50 | response.send(errorResponse); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/modules/upload/upload.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import * as iconv from 'iconv-lite'; 3 | import * as uuid from 'uuid'; 4 | import * as fs from 'fs'; 5 | import * as path from 'path'; 6 | import { join } from 'path'; 7 | 8 | const publicFolderPath = 'public'; 9 | 10 | @Injectable() 11 | export class UploadService { 12 | onModuleInit() { 13 | this.initializeFolders(); 14 | } 15 | 16 | async initializeFolders() { 17 | if (!fs.existsSync(publicFolderPath)) { 18 | fs.mkdirSync(publicFolderPath); 19 | console.log('publicFolderPath: ', publicFolderPath); 20 | } 21 | const filesPath = join(publicFolderPath, 'files'); 22 | if (!fs.existsSync(filesPath)) { 23 | fs.mkdirSync(filesPath); 24 | } 25 | } 26 | 27 | async uploadFile(file) { 28 | return this.saveFileToLocal(file); 29 | } 30 | 31 | async saveFileToLocal(file) { 32 | const dir = `${publicFolderPath}/files`; 33 | let basicName = file.originalname || file.filename; 34 | !basicName && (basicName = `random-${uuid.v4().slice(0, 10)}.png`); 35 | const filename = this.genFileName(basicName); 36 | const saveFilePath = path.join(dir, filename); 37 | fs.writeFileSync(saveFilePath, file.buffer); 38 | return `${saveFilePath.substring(6)}`; 39 | } 40 | 41 | genFileName(oldName) { 42 | const originalname = this.decodeFileName(oldName); 43 | const ext = originalname.split('.').pop(); 44 | const name = originalname.split('.').slice(0, -1).join('.'); 45 | const timestamp = new Date().getTime(); 46 | const randomStr = uuid.v4().slice(0, 10); 47 | return `${name}-nine-${timestamp}-nine-${randomStr}.${ext}`; 48 | } 49 | 50 | decodeFileName(originalName) { 51 | const decodedName = iconv.decode( 52 | Buffer.from(originalName, 'binary'), 53 | 'UTF-8', 54 | ); 55 | return decodedName; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### 2.x版本已经更新 2 | 3 | ![](./gitImgs/002.png)![项目图片]( 4 | 5 | ### 项目地址 6 | 7 | * github *后端项目地址:[项目地址](https://github.com/longyanjiang/Nine-chat-backend.git)* 前端模块地址: [项目地址](https://github.com/longyanjiang/Nine-chat-frontend.git) 8 | * 项目 [线上体验地址](https://music-chat.mmmss.com/#/http://chat.jiangly.com/#) 9 | 10 | ## 项目迁移 11 | 12 | * 一台云服务器 13 | * 一个mysql服务器即可 14 | * 一个私有文件远程存储的接口 15 | 项目已经提供了测试数据,拉下项目可直接运行,**typeorm**可自动化建表,无需额外操作,修改数据库地址即可快速迁移完成。 16 | 17 | ### 项目启动 18 | 19 | * 项目采用了 orm 操作数据库、所以只需要在`.env 配置文件`中配置上自己的数据库、就会初始化成功、orm会自动创建所需要的数据库 20 | * 如果不想自己建表填入带有root权限的数据库账号密码可以自动化建库,也可以填写场景好的数据库账号密码即可 21 | * 项目提供了一个测试数据库、可以直接使用、账号密码都有配置、可以自行操作即可 22 | * 前端部分 `pnpm install` `pnpm dev` 23 | * 后端部分 `pnpm install` `pnpm dev` 启动后初次会自动创建 超级管理员( super 123456 ),自动创建888官方房间, 首次默认会自动往曲库添加部分歌曲,如果想要添加到聊天室,super账号搜索歌曲,收藏就会加入官方聊天室,也可以通过接口`getAlbumList`传入专辑id添加歌曲,也可以在开发环境添加,也可以搜索添加。 24 | * 项目为`DEMO项目`,未配置与验证邮箱,也没有详细配置权限装饰器,仅有基础权限。 25 | 26 | ### 图片文件上传说明 27 | 28 | * 默认文件会上传到public下的files目录,默认basic目录下会有基础图片,所以本地开发环境不会显示图片,使用的是相对路径图片,或者自己拼接, 29 | * 将前端项目打包后的dist文件内容放入public下面,即可只启动后端项目 3000端口即可同时访问前后端,图片即可正常 30 | 31 | ### 免责声明 32 | 33 | 平台音乐数据来源于第三方网站,仅供学习交流使用,请勿用于商业用途。 34 | 35 | ### 更新历史 36 | 37 | ```html 38 | 1.x: 39 | 1、普通文字聊天、粘贴图片发送、在线搜索表情包发送等聊天功能 40 | 2、在线搜索歌曲、点歌、切割、收藏歌曲 41 | 3、歌曲实时播放,所有人共享一个实时歌单、一起听歌 42 | 4、实时修改个人信息资料 43 | 5、支持自定义专属背景 44 | 6、快捷键等待你的探索 45 | 46 | 2.x: 2022051 47 | 1.新增个人私有房间,支持用户创建自己独立的房间了 48 | 2.新增图片或文件发送,可直接粘贴到输入框即可 49 | 3.支持消息引用,点击引用的消息会自动滚动到指定位置 50 | 4.上拉平滑加载更多[修复1.0]上拉抖动问题 51 | 5.新增消息两分钟内可撤回 52 | 6.划分三级权限 超级管理员>房主>普通用户 支持加密房间 53 | 7.新增夜间主题和透明主题,支持部分快捷操作 54 | 8.新增部分快捷键 55 | 56 | 更多功能等你来提... 57 | ``` 58 | 59 | ## 项目部分截图 60 | 61 | ![](./gitImgs/001.jpg) 62 | 63 | ![](./gitImgs/003.jpg) 64 | 65 | ![](./gitImgs/004.jpg) 66 | 67 | ### 基础技术栈 68 | 69 | * 前端采用 vue + socker-io 未使用ui框架 70 | * 后端采用 nestjs + typeorm + mysql + socket.io 71 | 72 | > 佛系更新 有需要请 `issues`提 看到有需要就更新、没有就GG 73 | 74 | ### 关于更新 75 | 76 | 详情功能看预览地址,有bug就留言,基础模型功能都有,可以自己二次开发。 77 | 78 | 有时间也会更新部分功能上去、尽量做到简洁、方便各位移植和部署。 79 | 80 | 有创意或想法可以提issues,采纳会回复更新。 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snine-chat-backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "dev": "cross-env NODE_ENV=development nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "cross-env NODE_ENV=production node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "dependencies": { 24 | "@nestjs/common": "^8.0.0", 25 | "@nestjs/core": "^8.0.0", 26 | "@nestjs/platform-express": "^8.0.0", 27 | "@nestjs/serve-static": "^4.0.2", 28 | "@nestjs/typeorm": "9.0.1", 29 | "@types/jsonwebtoken": "^9.0.6", 30 | "@types/qs": "^6.9.15", 31 | "dotenv": "^16.4.5", 32 | "iconv-lite": "^0.6.3", 33 | "jsonwebtoken": "^9.0.2", 34 | "mysql2": "^3.11.0", 35 | "reflect-metadata": "^0.1.13", 36 | "rimraf": "^3.0.2", 37 | "rxjs": "^7.2.0", 38 | "typeorm": "^0.3.20", 39 | "uuid": "^10.0.0" 40 | }, 41 | "devDependencies": { 42 | "@nestjs/cli": "^8.0.0", 43 | "@nestjs/jwt": "^8.0.0", 44 | "@nestjs/passport": "^8.0.1", 45 | "@nestjs/platform-socket.io": "^8.2.0", 46 | "@nestjs/schematics": "^8.0.0", 47 | "@nestjs/swagger": "^5.1.4", 48 | "@nestjs/testing": "^8.0.0", 49 | "@nestjs/websockets": "^8.2.0", 50 | "@types/express": "^4.17.13", 51 | "@types/jest": "^27.0.1", 52 | "@types/node": "^16.0.0", 53 | "@types/supertest": "^2.0.11", 54 | "@typescript-eslint/eslint-plugin": "^5.0.0", 55 | "@typescript-eslint/parser": "^5.0.0", 56 | "axios": "^0.24.0", 57 | "bcryptjs": "^2.4.3", 58 | "cheerio": "^1.0.0-rc.10", 59 | "class-transformer": "^0.4.0", 60 | "class-validator": "^0.13.2", 61 | "cross-env": "^7.0.3", 62 | "eslint": "^8.0.1", 63 | "eslint-config-prettier": "^8.3.0", 64 | "eslint-plugin-prettier": "^4.0.0", 65 | "jest": "^27.2.5", 66 | "moment": "^2.29.1", 67 | "nestjs-config": "^1.4.10", 68 | "passport": "^0.5.0", 69 | "prettier": "^2.3.2", 70 | "socket.io": "^4.3.2", 71 | "source-map-support": "^0.5.20", 72 | "supertest": "^6.1.3", 73 | "swagger-ui-express": "^4.1.6", 74 | "ts-jest": "^27.0.3", 75 | "ts-loader": "^9.2.3", 76 | "ts-node": "^10.0.0", 77 | "tsconfig-paths": "^3.10.1", 78 | "typescript": "^4.3.5" 79 | }, 80 | "jest": { 81 | "moduleFileExtensions": [ 82 | "js", 83 | "json", 84 | "ts" 85 | ], 86 | "rootDir": "src", 87 | "testRegex": ".*\\.spec\\.ts$", 88 | "transform": { 89 | "^.+\\.(t|j)s$": "ts-jest" 90 | }, 91 | "collectCoverageFrom": [ 92 | "**/*.(t|j)s" 93 | ], 94 | "coverageDirectory": "../coverage", 95 | "testEnvironment": "node" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/utils/tools.ts: -------------------------------------------------------------------------------- 1 | import * as https from 'https'; 2 | 3 | export const https_get = async (url) => { 4 | let pipe = null; 5 | return new Promise((resovle, reject) => { 6 | https.get(url, (res) => { 7 | if (res.statusCode == 200) { 8 | res.on('data', (chunk) => { 9 | pipe += chunk; 10 | }); 11 | res.on('end', () => { 12 | const { data } = JSON.parse(pipe.split('null')[1]); 13 | if (data.length) { 14 | const { origip, location } = data[0]; 15 | resovle({ ip: origip, address: location.split(' ')[0] }); 16 | } else { 17 | reject('获取ip失败'); 18 | } 19 | }); 20 | } else { 21 | reject('获取ip失败'); 22 | } 23 | }); 24 | }); 25 | }; 26 | 27 | /** 28 | * @desc 转会数据格式,把映射对象转为数组 并把房主放到首位 29 | * @param onlineUserInfo 30 | * @param id 当前房间的管理员id 31 | * @returns 32 | */ 33 | export const formatOnlineUser = (onlineUserInfo = {}, id) => { 34 | const keys = Object.keys(onlineUserInfo); 35 | if (!keys.length) return []; 36 | let userInfo = Object.values(onlineUserInfo); 37 | let homeowner = null; 38 | const homeownerIndex = userInfo.findIndex((k: any) => k.id === id); 39 | homeownerIndex != -1 && (homeowner = userInfo.splice(homeownerIndex, 1)); 40 | homeownerIndex != -1 && (userInfo = [...homeowner, ...userInfo]); 41 | return userInfo; 42 | }; 43 | 44 | /** 45 | * @desc 把房间{}格式转为[] 并把888主房间放到第一位 并去除一些不需要的信息 46 | * @param roomListMap 47 | * @returns 48 | */ 49 | export const formatRoomlist = (roomListMap) => { 50 | const keys = Object.keys(roomListMap); 51 | if (!keys.length) return []; 52 | const roomIds = Object.keys(roomListMap).filter((key) => Number(key) !== 888); 53 | const adminRoom = roomListMap[888]; 54 | let roomList = []; 55 | roomIds.forEach((roomId) => 56 | roomList.push(getBasicRooInfo(roomListMap[roomId])), 57 | ); 58 | adminRoom && (roomList = [getBasicRooInfo(adminRoom), ...roomList]); 59 | return roomList; 60 | }; 61 | 62 | /** 63 | * @desc roomListMap[roomId]记录了房间所有信息,我们的房间列表只需要一部分,在这里做简化 64 | * @param roomDetailInfo 单个房间的所有信息 65 | */ 66 | export const getBasicRooInfo = (roomDetailInfo) => { 67 | const { room_admin_info, on_line_user_list, room_info } = roomDetailInfo; 68 | return Object.assign(room_info, { 69 | on_line_nums: on_line_user_list.length, 70 | room_user_nick: room_admin_info.user_nick, 71 | }); 72 | }; 73 | 74 | /** 75 | * @desc 延迟请求,爬虫遍历请求过快会导致被拉黑 76 | * @param time 时间 ms 77 | * @returns null 78 | */ 79 | export const delayRequest = (time) => { 80 | return new Promise((resolve) => { 81 | setTimeout(() => { 82 | resolve(true); 83 | }, time); 84 | }); 85 | }; 86 | 87 | /** 88 | * @desc 当前当前时间戳/1000 按秒计算 89 | * @params lastTimespace 传入上次时间戳则计算时间与现在的插值 90 | * @returns 91 | */ 92 | export const getTimeSpace = (lastTimespace = 0) => { 93 | const nowSpace = Math.round(new Date().getTime() / 1000); 94 | return lastTimespace ? nowSpace - lastTimespace : nowSpace; 95 | }; 96 | 97 | /** 98 | * @desc 随机生成验证码 99 | * @param len 验证码长度 100 | * @returns 101 | */ 102 | export const randomCode = (len = 6) => { 103 | let code = ''; 104 | for (let i = 0; i < len; i++) { 105 | const radom = Math.floor(Math.random() * 10); 106 | code += radom; 107 | } 108 | return code; 109 | }; 110 | -------------------------------------------------------------------------------- /src/constant/avatar.ts: -------------------------------------------------------------------------------- 1 | const avatarImages = [ 2 | 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fup.enterdesk.com%2Fedpic%2Fff%2F92%2Fde%2Fff92deb592080c5d113f3c589ad6ae5e.jpg&refer=http%3A%2F%2Fup.enterdesk.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1657539965&t=69e258a37ec430c0a05a0aa3c004a53d', 3 | 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fc-ssl.duitang.com%2Fuploads%2Fitem%2F202004%2F15%2F20200415141655_ihkmq.png&refer=http%3A%2F%2Fc-ssl.duitang.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1657539965&t=c7cf70d95b721ae21a08ff76e5571017', 4 | 'https://img2.baidu.com/it/u=2183774629,3758018921&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500', 5 | 'https://img2.baidu.com/it/u=4256150975,3329534358&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500', 6 | 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fc-ssl.duitang.com%2Fuploads%2Fitem%2F202006%2F17%2F2020061792937_ZXQCe.thumb.1000_0.jpeg&refer=http%3A%2F%2Fc-ssl.duitang.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1657539886&t=ab9746fc0e81775509291d9e11ed810e', 7 | 'https://img2.baidu.com/it/u=3136876758,3479953824&fm=26&fmt=auto', 8 | 'https://img0.baidu.com/it/u=3452625090,3453768659&fm=26&fmt=auto', 9 | 'https://img0.baidu.com/it/u=1800114517,4068633526&fm=26&fmt=auto', 10 | 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fc-ssl.duitang.com%2Fuploads%2Fitem%2F202006%2F01%2F20200601091140_yNxua.thumb.400_0.jpeg&refer=http%3A%2F%2Fc-ssl.duitang.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1657539965&t=ddc2af6b0404762088267b1c882a8ae6', 11 | 'https://img2.baidu.com/it/u=2080680443,1125776408&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500', 12 | 'https://img1.baidu.com/it/u=3076342820,26164290&fm=253&fmt=auto&app=138&f=JPEG?w=400&h=400', 13 | 'https://img1.baidu.com/it/u=999728032,2588274139&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500', 14 | 'https://img0.baidu.com/it/u=2348113243,2985082400&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500', 15 | 'https://img0.baidu.com/it/u=3219223402,3248683741&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=462', 16 | 'https://img1.baidu.com/it/u=1738531146,3909274171&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500', 17 | 'https://img1.baidu.com/it/u=194801324,3941016336&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=502', 18 | 'https://img1.baidu.com/it/u=4096419636,635686539&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500', 19 | 'https://img1.baidu.com/it/u=1063520357,634151768&fm=253&fmt=auto&app=138&f=JPEG?w=501&h=500', 20 | 'https://img1.baidu.com/it/u=1840640137,796009368&fm=253&fmt=auto&app=138&f=JPEG?w=400&h=400', 21 | 'https://img0.baidu.com/it/u=2808227484,2828723915&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500', 22 | 'https://img2.baidu.com/it/u=2139428454,11472061&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500', 23 | 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fup.enterdesk.com%2Fedpic%2Feb%2F52%2F72%2Feb52723f7f718af616c58036d144d9ac.jpeg&refer=http%3A%2F%2Fup.enterdesk.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1656446480&t=407531f57fe4df832c8049717e1d5270', 24 | 'https://img2.baidu.com/it/u=3880907952,1315091092&fm=253&fmt=auto&app=138&f=JPEG?w=400&h=400', 25 | 'https://img0.baidu.com/it/u=445630895,1325986660&fm=253&fmt=auto&app=138&f=JPEG?w=501&h=500', 26 | 'https://img1.baidu.com/it/u=128191971,1598406876&fm=253&fmt=auto&app=138&f=JPEG?w=480&h=480', 27 | 'https://img0.baidu.com/it/u=2874745006,3335345031&fm=253&fmt=auto&app=138&f=JPEG?w=400&h=400', 28 | 'https://img0.baidu.com/it/u=1570864321,1900317849&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500', 29 | 'https://img0.baidu.com/it/u=3467758223,1877547427&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500', 30 | 'https://img1.baidu.com/it/u=2984860093,2281446768&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500', 31 | 'https://img0.baidu.com/it/u=3539067204,3422500556&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=523', 32 | 'https://img2.baidu.com/it/u=3404678637,35400775&fm=253&fmt=auto&app=138&f=JPEG?w=400&h=400', 33 | ]; 34 | 35 | export const getRandomId = (min, max) => { 36 | min = Math.ceil(min); 37 | max = Math.floor(max); 38 | return Math.floor(Math.random() * (max - min + 1)) + min; 39 | }; 40 | 41 | export const randomAvatar = () => { 42 | const index = getRandomId(0, avatarImages.length - 1); 43 | return avatarImages[index]; 44 | }; 45 | -------------------------------------------------------------------------------- /src/modules/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { UserEntity } from './user.entity'; 2 | import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; 3 | import { hashSync, compareSync } from 'bcryptjs'; 4 | import { randomAvatar } from './../../constant/avatar'; 5 | import { InjectRepository } from '@nestjs/typeorm'; 6 | import { Repository } from 'typeorm'; 7 | import { JwtService } from '@nestjs/jwt'; 8 | 9 | @Injectable() 10 | export class UserService { 11 | constructor( 12 | @InjectRepository(UserEntity) 13 | private readonly UserModel: Repository, 14 | private readonly jwtService: JwtService, 15 | ) {} 16 | 17 | async onModuleInit() { 18 | await this.initAdmin(); 19 | } 20 | 21 | /** 22 | * @desc 初始化管理员账号 23 | * @param params 24 | * @returns 25 | */ 26 | async initAdmin() { 27 | const count = await this.UserModel.count({ where: { user_role: 'super' } }); 28 | if (count === 0) { 29 | const superUser = { 30 | user_name: 'super', 31 | user_password: hashSync('123456'), 32 | user_email: 'super@default.com', 33 | user_role: 'super', 34 | user_nick: '超级管理员', 35 | user_room_id: '888', 36 | user_avatar: '/basic/default-avatar.png', 37 | }; 38 | const user = await this.UserModel.save(superUser); 39 | Logger.debug( 40 | `初始化超级管理员账号成功,账号:${user.user_name},密码:123456`, 41 | ); 42 | } 43 | } 44 | 45 | /** 46 | * @desc 账号注册 47 | * @param params 48 | * @returns 49 | */ 50 | async register(params) { 51 | const { user_name, user_password, user_email, user_avatar } = params; 52 | params.user_password = hashSync(user_password); 53 | if (!user_avatar) { 54 | params.user_avatar = randomAvatar(); 55 | } 56 | const u: any = await this.UserModel.findOne({ 57 | where: [{ user_name }, { user_email }], 58 | }); 59 | if (u) { 60 | const tips = user_name == u.user_name ? '用户名' : '邮箱'; 61 | throw new HttpException(`该${tips}已经存在了!`, HttpStatus.BAD_REQUEST); 62 | } 63 | await this.UserModel.save(params); 64 | return true; 65 | } 66 | 67 | /** 68 | * @desc 账号登录 69 | * @param params 70 | * @returns 71 | */ 72 | async login(params): Promise { 73 | const { user_name, user_password } = params; 74 | const u: any = await this.UserModel.findOne({ 75 | where: [{ user_name }, { user_email: user_name }], 76 | }); 77 | if (!u) { 78 | throw new HttpException('该用户不存在!', HttpStatus.BAD_REQUEST); 79 | } 80 | const bool = compareSync(user_password, u.user_password); 81 | if (bool) { 82 | const { user_name, user_email, id: user_id, user_role, user_nick } = u; 83 | return { 84 | token: this.jwtService.sign({ 85 | user_name, 86 | user_nick, 87 | user_email, 88 | user_id, 89 | user_role, 90 | }), 91 | }; 92 | } else { 93 | throw new HttpException( 94 | { message: '账号或者密码错误!', error: 'please try again later.' }, 95 | HttpStatus.BAD_REQUEST, 96 | ); 97 | } 98 | } 99 | 100 | async getInfo(payload) { 101 | const { user_id: id, exp: failure_time } = payload; 102 | const u = await this.UserModel.findOne({ 103 | where: { id }, 104 | select: [ 105 | 'id', 106 | 'user_sex', 107 | 'user_name', 108 | 'user_nick', 109 | 'user_email', 110 | 'user_avatar', 111 | 'user_role', 112 | 'user_sign', 113 | 'user_room_bg', 114 | 'user_room_id', 115 | ], 116 | }); 117 | return { user_info: Object.assign(u, { user_id: id }), failure_time }; 118 | } 119 | 120 | async query(params) { 121 | return params; 122 | } 123 | 124 | /* 修改用户资料 */ 125 | async update(payload, params) { 126 | const { user_id } = payload; 127 | /* 只能修改这些项 */ 128 | const whiteListKeys = [ 129 | 'user_name', 130 | 'user_nick', 131 | 'user_sex', 132 | 'user_sign', 133 | 'user_avatar', 134 | 'user_room_bg', 135 | ]; 136 | const upateInfoData: any = {}; 137 | whiteListKeys.forEach( 138 | (key) => 139 | Object.keys(params).includes(key) && (upateInfoData[key] = params[key]), 140 | ); 141 | await this.UserModel.update({ id: user_id }, upateInfoData); 142 | return true; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/modules/chat/chat.service.ts: -------------------------------------------------------------------------------- 1 | import { RoomEntity } from './room.entity'; 2 | import { UserEntity } from './../user/user.entity'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { MessageEntity } from './message.entity'; 5 | import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'; 6 | import { Repository, In } from 'typeorm'; 7 | import { requestHtml } from 'src/utils/spider'; 8 | 9 | @Injectable() 10 | export class ChatService { 11 | constructor( 12 | @InjectRepository(MessageEntity) 13 | private readonly MessageModel: Repository, 14 | @InjectRepository(UserEntity) 15 | private readonly UserModel: Repository, 16 | @InjectRepository(RoomEntity) 17 | private readonly RoomModel: Repository, 18 | ) {} 19 | 20 | async onModuleInit() { 21 | await this.initOfficialRoom(); 22 | } 23 | 24 | /* 初始化官方直播间 */ 25 | async initOfficialRoom() { 26 | const count = await this.RoomModel.count({ where: { room_id: 888 } }); 27 | if (count === 0) { 28 | const basicRoomInfo = { 29 | room_name: '官方直播间', 30 | room_user_id: 1, 31 | room_id: 888, 32 | room_logo: '/basic/room-default-logo.png', 33 | room_need_password: 1, 34 | room_notice: '欢迎来到官方直播间', 35 | }; 36 | const room = await this.RoomModel.save(basicRoomInfo); 37 | Logger.debug('官方直播间初始化成功', room); 38 | } 39 | } 40 | 41 | /* 查询历史消息 */ 42 | async history(params) { 43 | const { page = 1, pagesize = 300, room_id = 888 } = params; 44 | const messageInfo = await this.MessageModel.find({ 45 | where: { room_id }, 46 | order: { id: 'DESC' }, 47 | skip: (page - 1) * pagesize, 48 | take: pagesize, 49 | }); 50 | 51 | /* 收集此次所有的用户id 包含发送消息的和被艾特消息的 */ 52 | const userIds = []; 53 | const quoteMessageIds = []; 54 | 55 | messageInfo.forEach((t) => { 56 | !userIds.includes(t.user_id) && userIds.push(t.user_id); 57 | !userIds.includes(t.quote_user_id) && 58 | t.quote_user_id && 59 | userIds.push(t.quote_user_id); 60 | !quoteMessageIds.includes(t.quote_message_id) && 61 | t.quote_message_id && 62 | quoteMessageIds.push(t.quote_message_id); 63 | }); 64 | 65 | const userInfoList = await this.UserModel.find({ 66 | where: { id: In(userIds) }, 67 | select: ['id', 'user_nick', 'user_avatar', 'user_role'], 68 | }); 69 | 70 | userInfoList.forEach((t: any) => (t.user_id = t.id)); 71 | 72 | /* 相关联的引用消息的信息 */ 73 | const messageInfoList = await this.MessageModel.find({ 74 | where: { id: In(quoteMessageIds) }, 75 | select: [ 76 | 'id', 77 | 'message_content', 78 | 'message_type', 79 | 'user_id', 80 | 'message_status', 81 | ], 82 | }); 83 | 84 | /* TODO 消息列表中的用户 */ 85 | 86 | /* 对引用消息通过user_id拿到此条消息的user_nick 并修改字段名称 */ 87 | messageInfoList.forEach((t: any) => { 88 | t.quote_user_nick = userInfoList.find( 89 | (k: any) => k.user_id === t.user_id, 90 | )['user_nick']; 91 | t.quote_message_content = JSON.parse(t.message_content); 92 | t.quote_message_type = t.message_type; 93 | t.quote_message_status = t.message_status; 94 | t.quote_message_id = t.id; 95 | t.quote_user_id = t.user_id; 96 | delete t.message_content; 97 | delete t.message_type; 98 | }); 99 | 100 | /* 组装信息,带上发消息人的用户信息 已经引用的那条消息的用户和消息信息 */ 101 | messageInfo.forEach((t: any) => { 102 | t.user_info = userInfoList.find((k: any) => k.user_id === t.user_id); 103 | t.quote_info = messageInfoList.find((k) => k.id === t.quote_message_id); 104 | t.message_status === -1 && 105 | (t.message_content = `${t.user_info.user_nick}撤回了一条消息`); 106 | t.message_status === -1 && (t.message_type = 'info'); 107 | t.message_content && 108 | t.message_status === 1 && 109 | (t.message_content = t.message_content); 110 | }); 111 | 112 | return messageInfo.reverse(); 113 | } 114 | 115 | /* 在线搜索表情包 */ 116 | async emoticon(params) { 117 | const { keyword } = params; 118 | const url = `https://www.pkdoutu.com/search?keyword=${encodeURIComponent( 119 | keyword, 120 | )}`; 121 | const $ = await requestHtml(url); 122 | const list = []; 123 | $('.search-result .pic-content .random_picture a').each((index, node) => { 124 | const url = $(node).find('img').attr('data-original'); 125 | url && list.push(url); 126 | }); 127 | return list; 128 | } 129 | 130 | /** 131 | * @desc 创建个人聊天室 132 | * @param params 133 | */ 134 | async createRoom(params, req) { 135 | const { user_id: room_user_id } = req.payload; 136 | const { room_id } = params; 137 | const { user_room_id, user_avatar } = await this.UserModel.findOne({ 138 | where: { id: room_user_id }, 139 | select: ['user_room_id', 'user_avatar'], 140 | }); 141 | if (user_room_id) { 142 | throw new HttpException( 143 | `您已经创建过了,拒绝重复创建!`, 144 | HttpStatus.BAD_REQUEST, 145 | ); 146 | } 147 | const count = await this.RoomModel.count({ where: { room_id } }); 148 | if (count) { 149 | throw new HttpException( 150 | `房间ID[${room_id}]已经被注册了,换一个试试吧!`, 151 | HttpStatus.BAD_REQUEST, 152 | ); 153 | } 154 | /* 客户端没传房间头像就默认使用用户的头像 */ 155 | const room = Object.assign({ room_user_id }, params); 156 | !room.room_logo && (room.room_logo = user_avatar); 157 | await this.RoomModel.save(room); 158 | await this.UserModel.update( 159 | { id: room_user_id }, 160 | { user_room_id: room_id }, 161 | ); 162 | return true; 163 | } 164 | 165 | /* 查询房间信息 */ 166 | async roomInfo(params) { 167 | const { room_id } = params; 168 | return await this.RoomModel.findOne({ 169 | where: { room_id }, 170 | select: [ 171 | 'room_id', 172 | 'room_user_id', 173 | 'room_logo', 174 | 'room_bg_img', 175 | 'room_need_password', 176 | 'room_notice', 177 | 'room_name', 178 | ], 179 | }); 180 | } 181 | 182 | /* 修改自己的房间信息 */ 183 | async updateRoomInfo(params, payload) { 184 | const { user_id } = payload; 185 | const { room_id } = params; 186 | const room = await this.RoomModel.findOne({ 187 | where: { room_user_id: user_id, room_id }, 188 | }); 189 | if (!room) { 190 | throw new HttpException( 191 | `您无权操作当前房间:房间ID[${room_id}]`, 192 | HttpStatus.BAD_REQUEST, 193 | ); 194 | } 195 | /* 个人修改允许修改这些字段 */ 196 | const whiteListKeys = [ 197 | 'room_bg_img', 198 | 'room_name', 199 | 'room_notice', 200 | 'room_need_password', 201 | 'room_password', 202 | 'room_logo', 203 | ]; 204 | const updateInfo = {}; 205 | whiteListKeys.forEach( 206 | (key) => 207 | Object.keys(params).includes(key) && (updateInfo[key] = params[key]), 208 | ); 209 | await this.RoomModel.update({ room_id }, updateInfo); 210 | return true; 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/modules/music/music.service.ts: -------------------------------------------------------------------------------- 1 | import { CollectEntity } from './collect.entity'; 2 | import { MusicEntity } from './music.entity'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; 5 | import { Repository } from 'typeorm'; 6 | import { getAlbumList, initMusicSheet, searchMusic } from 'src/utils/spider'; 7 | import { addAlbumDto } from './dto/addAlbum.dto'; 8 | 9 | @Injectable() 10 | export class MusicService { 11 | constructor( 12 | @InjectRepository(MusicEntity) 13 | private readonly MusicModel: Repository, 14 | @InjectRepository(CollectEntity) 15 | private readonly CollectModel: Repository, 16 | ) {} 17 | 18 | /* 初始化给官方聊天室将首页推荐的专辑加入到库里 */ 19 | async onModuleInit() { 20 | await this.initMusicList(); 21 | // this.getAlbumList({ albumId: 3495945238 }); 22 | } 23 | 24 | /* 通过专辑ID添加当前专辑歌曲到曲库 */ 25 | async getAlbumList(params: addAlbumDto) { 26 | const { page = 1, size = 99, albumId } = params; 27 | const musicList = await getAlbumList({ albumId, page, size }); 28 | console.log(`当前专辑查询到了歌曲数量为${musicList.length},排队加入中`); 29 | const addList = []; 30 | for (const music of musicList) { 31 | const { music_mid } = music; 32 | const existingMusic = await this.MusicModel.findOne({ 33 | where: { music_mid }, 34 | }); 35 | if (!existingMusic) { 36 | await this.MusicModel.save(music); 37 | addList.push(music); 38 | } 39 | } 40 | console.log('本次加入曲库的歌曲数量: ', addList.length); 41 | return { 42 | tips: '当前为成功加入曲库的歌曲', 43 | data: addList, 44 | }; 45 | } 46 | 47 | /** 48 | * @desc 项目启动的时候初始化一下基础歌单,如果歌单没有歌曲、就会去加载酷我专辑页面的前三个专辑的各30首歌曲 49 | * @params pageSize 需要几个歌单 默认10个 50 | * 一个歌单下默认拿10首歌曲 自己配置默认数量即可 51 | * 用于没有人点歌的时候随机播放的歌曲 52 | * 想要自己选歌单 参考此页面 https://kuwo.cn/playlists 修改page pageSize即可 只用于项目初始化 53 | * @returns musicList [] 返回歌曲列表 54 | */ 55 | async initMusicList() { 56 | const params = { page: 1, pageSize: 10 }; 57 | const musicCount = await this.MusicModel.count(); 58 | if (musicCount) { 59 | return console.log(`当前曲库共有${musicCount}首音乐,初始化会默认填充曲库,具体添加方法查看readme`); 60 | } else { 61 | console.log(`>>>>>>>>>>>>> 当前曲库没有任何音乐, 将默认为您随机添加一些歌曲。`); 62 | } 63 | 64 | const musicList = await initMusicSheet(params); 65 | const addList = []; 66 | for (const music of musicList) { 67 | const { music_mid } = music; 68 | const existingMusic = await this.MusicModel.findOne({ 69 | where: { music_mid }, 70 | }); 71 | if (!existingMusic) { 72 | await this.MusicModel.save(music); 73 | addList.push(music); 74 | } 75 | } 76 | /* 歌曲建议少量 可以相对减少或者分批存入 */ 77 | musicList.length && console.log(`>>>>>>>>>>>>> 初始化歌单成功、共获取${addList.length}首歌曲。`); 78 | return musicList; 79 | } 80 | 81 | /* 查询搜索音乐 */ 82 | async search(params) { 83 | const { keyword } = params; 84 | let musicList: any; 85 | try { 86 | const decodeKeyword = encodeURIComponent(keyword); 87 | const url = `https://kuwo.cn/search/searchMusicBykeyWord?vipver=1&client=kt&ft=music&cluster=0&strategy=2012&encoding=utf8&rformat=json&mobi=1&issubtitle=1&show_copyright_off=1&pn=0&rn=99&all=${decodeKeyword}`; 88 | const res: any = await searchMusic(url); 89 | console.log('res.abslist.length: ', res.abslist.length); 90 | if (res.abslist.length) { 91 | musicList = res.abslist.map((t, index) => { 92 | const { 93 | DC_TARGETID: music_mid, 94 | DURATION: music_duration, 95 | ALBUM: music_album, 96 | ARTIST: music_singer, 97 | web_albumpic_short: music_albumpic, 98 | web_artistpic_short: music_cover, 99 | NAME: music_name, 100 | MVFLAG: music_hasmv, 101 | payInfo, 102 | } = t; 103 | const { limitfree, feeType } = payInfo; 104 | const isPlay = Number(feeType?.vip) === 0 || Number(limitfree) === 1; 105 | return { 106 | music_mid, 107 | music_duration, 108 | music_album, 109 | music_singer, 110 | music_albumpic: ``, 111 | music_cover: music_album 112 | ? `https://img2.kuwo.cn/star/albumcover/${music_albumpic}` 113 | : `https://img1.kuwo.cn/star/starheads/${music_cover}`, 114 | music_name, 115 | music_hasmv, 116 | isPlay, 117 | }; 118 | }); 119 | } 120 | } catch (error) { 121 | throw new HttpException(`没有搜索到歌曲`, HttpStatus.BAD_REQUEST); 122 | } 123 | return musicList; 124 | } 125 | 126 | /** 127 | * @desc 收藏音乐 128 | * 1. 所有人收藏的歌曲都要加入到歌曲曲库中 当前没有热门歌曲推荐机制、管理员权限的人点的歌曲就全部加入到热门列表 129 | * 2. 管理员收藏的歌默认其为推荐状态 如果歌曲存在曲库就改变其推荐状态 不存在就加入到曲库 130 | * @returns 131 | */ 132 | async collectMusic(payload, params) { 133 | const { music_mid } = params; 134 | const { user_id, user_role } = payload; 135 | const c = await this.CollectModel.count({ 136 | where: { music_mid, user_id, is_delete: 1 }, 137 | }); 138 | if (c > 0) { 139 | throw new HttpException(`您已经收藏过这首歌了!`, HttpStatus.BAD_REQUEST); 140 | } 141 | const music = Object.assign({ user_id }, params); 142 | await this.CollectModel.save(music); 143 | user_role === 'admin' && (music.is_recommend = 1); 144 | const m = await this.MusicModel.count({ where: { music_mid } }); 145 | if (m) { 146 | return await this.MusicModel.update({ music_mid }, { is_recommend: 1 }); 147 | } 148 | return await this.MusicModel.save(music); 149 | } 150 | 151 | /* 获取收藏歌单 */ 152 | async collectList(payload, params) { 153 | const { page = 1, pagesize = 30 } = params; 154 | if (!payload) { 155 | throw new HttpException('请先登录', HttpStatus.UNAUTHORIZED); 156 | } 157 | const { user_id } = payload; 158 | return await this.CollectModel.find({ 159 | where: { user_id, is_delete: 1 }, 160 | order: { id: 'DESC' }, 161 | skip: (page - 1) * pagesize, 162 | take: pagesize, 163 | cache: true, 164 | }); 165 | } 166 | 167 | /* 移除收藏音乐 */ 168 | async removeCollect(payload, params) { 169 | const { music_mid } = params; 170 | const { user_id } = payload; 171 | const u = await this.CollectModel.findOne({ 172 | where: { user_id, music_mid }, 173 | }); 174 | if (u) { 175 | await this.CollectModel.update({ user_id, music_mid }, { is_delete: -1 }); 176 | return '移除完成'; 177 | } else { 178 | throw new HttpException('无权移除此歌曲!', HttpStatus.BAD_REQUEST); 179 | } 180 | } 181 | 182 | /** 183 | * @desc 获取热门歌曲 拿到当前房主收藏的音乐作为推荐音乐 userId此处为管理房主id,请注意自己预设时候的id 184 | * @returns 185 | */ 186 | async hot(params) { 187 | const { page = 1, pagesize = 30, user_id = 1 } = params; 188 | return await this.CollectModel.find({ 189 | where: { user_id, is_delete: 1 }, 190 | order: { id: 'DESC' }, 191 | skip: (page - 1) * pagesize, 192 | take: pagesize, 193 | cache: true, 194 | }); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/utils/spider.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | import axios from 'axios'; 3 | import * as Qs from 'qs'; 4 | import { HttpException, HttpStatus } from '@nestjs/common'; 5 | 6 | /** 7 | * @desc 请求页面通过cherrion格式化文档返回给业务处理 8 | * @param url 请求地址 9 | * @returns 10 | */ 11 | export const requestHtml = async (url) => { 12 | const body: any = await requestInterface(url); 13 | return cheerio.load(body, { decodeEntities: false }); 14 | }; 15 | 16 | /** 17 | * @desc axios调用三方接口使用 18 | */ 19 | export const requestInterface = async (url, param = {}, method: any = 'GET') => { 20 | return new Promise((resolve, reject) => { 21 | axios({ 22 | method, 23 | headers: { 24 | Accept: 'application/json, text/plain, */*', 25 | 'Accept-Language': 'zh-CN,zh;q=0.9', 26 | Cookie: 27 | 'Hm_lvt_cdb524f42f0ce19b169a8071123a4797=1697662592; Hm_lpvt_cdb524f42f0ce19b169a8071123a4797=1697662592; _ga=GA1.2.2131023439.1697662592; _gid=GA1.2.1362874298.1697662592; _gat=1; Hm_Iuvt_cdb524f42f0cer9b268e4v7y735ewrq2324=prwfpcQsp6d6Rzx7tyT6DmFtFz4HpFhx; _ga_ETPBRPM9ML=GS1.2.1697662592.1.1.1697662603.49.0.0', 28 | Referer: 'https://www.kuwo.cn/playlist_detail/1082685104', 29 | Secret: 'f3a6842235cf869e96ba38ad80a55e1eaaddc8e403708cc68672e145d40f0b170394efe6', 30 | 'User-Agent': 31 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36', 32 | 'sec-ch-ua': '"Chromium";v="116", "Not)A;Brand";v="24", "Google Chrome";v="116"', 33 | 'sec-ch-ua-mobile': '?0', 34 | 'sec-ch-ua-platform': '"macOS"', 35 | }, 36 | url, 37 | data: Qs.stringify(param), 38 | }) 39 | .then((res) => { 40 | resolve(res.data); 41 | }) 42 | .catch((err) => { 43 | reject(err); 44 | }); 45 | }); 46 | }; 47 | 48 | /** 49 | * @desc 搜索音乐 50 | * @param url 51 | * @returns 52 | */ 53 | export const searchMusic = async (url) => { 54 | return new Promise((resolve, reject) => { 55 | axios({ 56 | url, 57 | method: 'GET', 58 | headers: { 59 | Accept: 'application/json, text/plain, */*', 60 | 'Accept-Language': 'zh-CN,zh;q=0.9', 61 | Cookie: 62 | 'Hm_lvt_cdb524f42f0ce19b169a8071123a4797=1697662592; Hm_lpvt_cdb524f42f0ce19b169a8071123a4797=1697662592; _ga=GA1.2.2131023439.1697662592; _gid=GA1.2.1362874298.1697662592; _gat=1; Hm_Iuvt_cdb524f42f0cer9b268e4v7y735ewrq2324=prwfpcQsp6d6Rzx7tyT6DmFtFz4HpFhx; _ga_ETPBRPM9ML=GS1.2.1697662592.1.1.1697662603.49.0.0', 63 | Referer: 'https://www.kuwo.cn/playlist_detail/1082685104', 64 | Secret: 'f3a6842235cf869e96ba38ad80a55e1eaaddc8e403708cc68672e145d40f0b170394efe6', 65 | 'User-Agent': 66 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36', 67 | 'sec-ch-ua': '"Chromium";v="116", "Not)A;Brand";v="24", "Google Chrome";v="116"', 68 | 'sec-ch-ua-mobile': '?0', 69 | 'sec-ch-ua-platform': '"macOS"', 70 | }, 71 | }) 72 | .then((res) => { 73 | resolve(res.data); 74 | }) 75 | .catch((err) => { 76 | reject(err); 77 | }); 78 | }); 79 | }; 80 | 81 | /** 82 | * @desc 获取到首页的推荐里面的所有歌单的Id 83 | * 默认拿前3个歌单、需要更多自己配置页码 一份分类会拿到前30首 默认初始化拿90首 需要更多自己配置页码 84 | * 参考此页面 https://kuwo.cn/playlists 85 | * return 歌单ids 86 | */ 87 | export const getMusicSheetIds = async (page = 1, pagesize = 3) => { 88 | const reqId = 'be7f2ce0-4518-11ec-b987-313c77db29de'; 89 | const url = `https://www.kuwo.cn/api/www/classify/playlist/getRcmPlayList?pn=${page}&rn=${pagesize}&order=new&httpsStatus=1&reqId=${reqId}`; 90 | 91 | const headers = { 92 | Accept: 'application/json, text/plain, */*', 93 | 'Accept-Encoding': 'gzip, deflate, br, zstd', 94 | 'Accept-Language': 'zh-CN,zh;q=0.9', 95 | Connection: 'keep-alive', 96 | Cookie: 97 | '_ga=GA1.2.680482831.1722568034; _gid=GA1.2.1552324420.1722568034; Hm_lvt_cdb524f42f0ce19b169a8071123a4797=1722568034; HMACCOUNT=FABA7B07CE51FB8D; _gat=1; Hm_lpvt_cdb524f42f0ce19b169a8071123a4797=1722569034; _ga_ETPBRPM9ML=GS1.2.1722568034.1.1.1722569053.41.0.0; Hm_Iuvt_cdb524f42f23cer9b268564v7y735ewrq2324=iGpzS5J8JQWNkyQFESxRDWry7ntfGzmi', 98 | Host: 'www.kuwo.cn', 99 | Referer: 'https://www.kuwo.cn/play_detail/7201115', 100 | 'Sec-Ch-Ua': '"Not/A)Brand";v="8", "Chromium";v="126", "Google Chrome";v="126"', 101 | 'Sec-Ch-Ua-Mobile': '?0', 102 | 'Sec-Ch-Ua-Platform': '"macOS"', 103 | 'Sec-Fetch-Dest': 'empty', 104 | 'Sec-Fetch-Mode': 'cors', 105 | 'Sec-Fetch-Site': 'same-origin', 106 | Secret: '4a17fbd8421222139474f7df20c3e06b4d9c898573adee7e3eee0fffc1fc08e5013d8af2', 107 | 'User-Agent': 108 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', 109 | }; 110 | 111 | try { 112 | const response = await axios.get(url, { headers }); 113 | const list = response?.data?.data?.data || []; 114 | const listIds = list.map((t) => t.id); 115 | return listIds; 116 | } catch (error) { 117 | console.error('Error fetching music source:', error.message); 118 | throw new HttpException(`请求失败: ${error.message}`, HttpStatus.INTERNAL_SERVER_ERROR); 119 | } 120 | 121 | try { 122 | const res: any = await requestInterface(url); 123 | const recommendMusicClassId = res.data.data.map((t) => t.id); 124 | return recommendMusicClassId; 125 | } catch (error) { 126 | return []; 127 | } 128 | }; 129 | 130 | /** 131 | * @desc 结合上面两个方法我们可以一次获得多个分类的音乐用于初始化歌单 具体数量自己配置 132 | */ 133 | export const initMusicSheet = async ({ page = 1, pageSize = 10 }) => { 134 | /* 拿到推荐页面的歌单列表 https://kuwo.cn/playlists */ 135 | const recommnetClassIds = await getMusicSheetIds(page, pageSize); 136 | const task = []; 137 | recommnetClassIds.forEach((id) => 138 | task.push( 139 | getAlbumList({ 140 | page: 1, 141 | size: 10, 142 | albumId: id, 143 | }), 144 | ), 145 | ); 146 | const result = await Promise.all(task); 147 | const sumMusic = []; 148 | const cacheMusicMids = []; 149 | result.forEach((musicList) => { 150 | musicList.forEach((music) => { 151 | !cacheMusicMids.includes(music.music_mid) && sumMusic.push(music); 152 | cacheMusicMids.push(music.music_mid); 153 | }); 154 | }); 155 | /* 偶尔会有重复歌曲 需要在这里去重一下 */ 156 | return sumMusic; 157 | }; 158 | 159 | /** 160 | * @desc 通过mid获取音乐的详情信息,包含封面 歌词等等 161 | * @param mid 162 | */ 163 | export const getMusicDetail = async (mid) => { 164 | const lrcUrl = `https://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId=${mid}`; 165 | const headers = { 166 | Accept: 'application/json, text/plain, */*', 167 | 'Accept-Encoding': 'gzip, deflate, br, zstd', 168 | 'Accept-Language': 'zh-CN,zh;q=0.9', 169 | Connection: 'keep-alive', 170 | Cookie: 171 | '_ga=GA1.2.680482831.1722568034; _gid=GA1.2.1552324420.1722568034; Hm_lvt_cdb524f42f0ce19b169a8071123a4797=1722568034; HMACCOUNT=FABA7B07CE51FB8D; _gat=1; Hm_lpvt_cdb524f42f0ce19b169a8071123a4797=1722569034; _ga_ETPBRPM9ML=GS1.2.1722568034.1.1.1722569053.41.0.0; Hm_Iuvt_cdb524f42f23cer9b268564v7y735ewrq2324=iGpzS5J8JQWNkyQFESxRDWry7ntfGzmi', 172 | Host: 'www.kuwo.cn', 173 | Referer: 'https://www.kuwo.cn/play_detail/7201115', 174 | 'Sec-Ch-Ua': '"Not/A)Brand";v="8", "Chromium";v="126", "Google Chrome";v="126"', 175 | 'Sec-Ch-Ua-Mobile': '?0', 176 | 'Sec-Ch-Ua-Platform': '"macOS"', 177 | 'Sec-Fetch-Dest': 'empty', 178 | 'Sec-Fetch-Mode': 'cors', 179 | 'Sec-Fetch-Site': 'same-origin', 180 | Secret: '4a17fbd8421222139474f7df20c3e06b4d9c898573adee7e3eee0fffc1fc08e5013d8af2', 181 | 'User-Agent': 182 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', 183 | }; 184 | const lrcData = await axios.get(lrcUrl, { headers }); 185 | if (lrcData?.status === 200 || lrcData?.data?.status === 200 || !lrcData?.data?.data) { 186 | const { lrclist, songinfo } = lrcData?.data?.data; 187 | const { artist, pic, duration, score100, album, songTimeMinutes, songName, id: mid } = songinfo; 188 | return { 189 | music_lrc: lrclist, 190 | music_info: { 191 | music_singer: artist, 192 | music_cover: pic.replace('http://img1.kwcdn.kuwo.cn', 'https://img2.kuwo.cn'), 193 | music_albumpic: pic.replace('http://img1.kwcdn.kuwo.cn', 'https://img2.kuwo.cn'), 194 | music_duration: duration, 195 | music_score100: score100, 196 | music_album: album, 197 | music_name: songName, 198 | music_song_time_minutes: songTimeMinutes, 199 | music_mid: mid, 200 | choose_user_id: null, 201 | }, 202 | reqid: lrcData.data.reqid, 203 | }; 204 | } else { 205 | console.log('请求歌曲信息失败', lrcData); 206 | throw new HttpException(`没有找到歌曲信息!`, HttpStatus.BAD_REQUEST); 207 | } 208 | }; 209 | 210 | /** 211 | * @desc 通过mid拿到歌曲播放临时地址 212 | * @param mid 213 | * @returns 214 | */ 215 | export const getMusicSrc = async (mid) => { 216 | const url = `https://www.kuwo.cn/api/v1/www/music/playUrl?mid=${mid}&type=music&httpsStatus=1&reqId=18a7dec3Xb4b9X4451Xb675X58849ba5e064&plat=web_www&from=`; 217 | const headers = { 218 | Accept: 'application/json, text/plain, */*', 219 | 'Accept-Encoding': 'gzip, deflate, br, zstd', 220 | 'Accept-Language': 'zh-CN,zh;q=0.9', 221 | Connection: 'keep-alive', 222 | Cookie: 223 | '_ga=GA1.2.680482831.1722568034; _gid=GA1.2.1552324420.1722568034; Hm_lvt_cdb524f42f0ce19b169a8071123a4797=1722568034; HMACCOUNT=FABA7B07CE51FB8D; _gat=1; Hm_lpvt_cdb524f42f0ce19b169a8071123a4797=1722569034; _ga_ETPBRPM9ML=GS1.2.1722568034.1.1.1722569053.41.0.0; Hm_Iuvt_cdb524f42f23cer9b268564v7y735ewrq2324=iGpzS5J8JQWNkyQFESxRDWry7ntfGzmi', 224 | Host: 'www.kuwo.cn', 225 | Referer: 'https://www.kuwo.cn/play_detail/7201115', 226 | 'Sec-Ch-Ua': '"Not/A)Brand";v="8", "Chromium";v="126", "Google Chrome";v="126"', 227 | 'Sec-Ch-Ua-Mobile': '?0', 228 | 'Sec-Ch-Ua-Platform': '"macOS"', 229 | 'Sec-Fetch-Dest': 'empty', 230 | 'Sec-Fetch-Mode': 'cors', 231 | 'Sec-Fetch-Site': 'same-origin', 232 | Secret: '4a17fbd8421222139474f7df20c3e06b4d9c898573adee7e3eee0fffc1fc08e5013d8af2', 233 | 'User-Agent': 234 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', 235 | }; 236 | 237 | try { 238 | const response = await axios.get(url, { headers }); 239 | if (response.data.success === true) { 240 | return response.data.data.url || ''; 241 | } else { 242 | throw new HttpException(`没有找到歌曲地址!`, HttpStatus.BAD_REQUEST); 243 | } 244 | } catch (error) { 245 | console.error('Error fetching music source:', error); 246 | throw new HttpException(`请求失败: ${error.message}`, HttpStatus.INTERNAL_SERVER_ERROR); 247 | } 248 | }; 249 | 250 | /** 251 | * @desc 通过专辑id拿到专辑的歌曲列表 252 | * @param mid 253 | * @returns 254 | */ 255 | export const getAlbumList = async (opt) => { 256 | const { albumId, page, size } = opt; 257 | const url = `https://kuwo.cn/api/www/playlist/playListInfo?pid=${albumId}&pn=${page}&rn=${size}&httpsStatus=1&reqId=59016150-50da-11ef-a3a1-3197dce36158&plat=web_www&from=`; 258 | const headers = { 259 | Accept: 'application/json, text/plain, */*', 260 | 'Accept-Encoding': 'gzip, deflate, br, zstd', 261 | 'Accept-Language': 'zh-CN,zh;q=0.9', 262 | Connection: 'keep-alive', 263 | Cookie: 264 | '_ga=GA1.2.680482831.1722568034; _gid=GA1.2.1552324420.1722568034; Hm_lvt_cdb524f42f0ce19b169a8071123a4797=1722568034; HMACCOUNT=FABA7B07CE51FB8D; _gat=1; Hm_lpvt_cdb524f42f0ce19b169a8071123a4797=1722569034; _ga_ETPBRPM9ML=GS1.2.1722568034.1.1.1722569053.41.0.0; Hm_Iuvt_cdb524f42f23cer9b268564v7y735ewrq2324=iGpzS5J8JQWNkyQFESxRDWry7ntfGzmi', 265 | Host: 'www.kuwo.cn', 266 | Referer: 'https://www.kuwo.cn/play_detail/7201115', 267 | 'Sec-Ch-Ua': '"Not/A)Brand";v="8", "Chromium";v="126", "Google Chrome";v="126"', 268 | 'Sec-Ch-Ua-Mobile': '?0', 269 | 'Sec-Ch-Ua-Platform': '"macOS"', 270 | 'Sec-Fetch-Dest': 'empty', 271 | 'Sec-Fetch-Mode': 'cors', 272 | 'Sec-Fetch-Site': 'same-origin', 273 | Secret: '4a17fbd8421222139474f7df20c3e06b4d9c898573adee7e3eee0fffc1fc08e5013d8af2', 274 | 'User-Agent': 275 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', 276 | }; 277 | try { 278 | const response = await axios.get(url, { headers }); 279 | const musicList = response.data.data.musicList || []; 280 | if (musicList.length) { 281 | return musicList.map((t) => { 282 | const { 283 | album: music_album, 284 | name: music_name, 285 | duration: music_duration, 286 | artist: music_singer, 287 | albumid: music_mid, 288 | } = t; 289 | return { 290 | music_name, 291 | music_album, 292 | music_duration, 293 | music_singer, 294 | music_mid, 295 | }; 296 | }); 297 | } else { 298 | throw new HttpException(`没有找到专辑歌曲!`, HttpStatus.BAD_REQUEST); 299 | } 300 | } catch (error) { 301 | console.error('Error fetching music source:', error.message); 302 | throw new HttpException(`请求失败: ${error.message}`, HttpStatus.INTERNAL_SERVER_ERROR); 303 | } 304 | }; 305 | -------------------------------------------------------------------------------- /src/modules/chat/chat.getaway.ts: -------------------------------------------------------------------------------- 1 | import { formatOnlineUser, formatRoomlist } from '../../utils/tools'; 2 | import { RoomEntity } from './room.entity'; 3 | import { MusicEntity } from '../music/music.entity'; 4 | import { MessageEntity } from './message.entity'; 5 | import { UserEntity } from '../user/user.entity'; 6 | import { InjectRepository } from '@nestjs/typeorm'; 7 | import { Repository } from 'typeorm'; 8 | import { getRandomId } from '../../constant/avatar'; 9 | import { getMusicDetail, getMusicSrc } from 'src/utils/spider'; 10 | import { getTimeSpace } from 'src/utils/tools'; 11 | 12 | import { 13 | WebSocketGateway, 14 | WebSocketServer, 15 | SubscribeMessage, 16 | } from '@nestjs/websockets'; 17 | import { Server, Socket } from 'socket.io'; 18 | import { verifyToken } from 'src/utils/verifyToken'; 19 | 20 | @WebSocketGateway({ 21 | path: '/chat', 22 | allowEIO3: true, 23 | cors: { 24 | origin: /.*/, 25 | credentials: true, 26 | }, 27 | }) 28 | export class WsChatGateway { 29 | constructor( 30 | @InjectRepository(UserEntity) 31 | private readonly UserModel: Repository, 32 | @InjectRepository(MessageEntity) 33 | private readonly MessageModel: Repository, 34 | @InjectRepository(MusicEntity) 35 | private readonly MusicModel: Repository, 36 | @InjectRepository(RoomEntity) 37 | private readonly RoomModel: Repository, 38 | ) {} 39 | @WebSocketServer() private socket: Server; 40 | 41 | private clientIdMap: any = {}; // 记录clientId 和userId roomId的映射关系 { client.id: { user_id, room_id }} 42 | private onlineUserInfo: any = {}; // 在线用户信息 43 | private chooseMusicTimeSpace: any = {}; // 记录每位用户的点歌时间 限制30s点一首 44 | private room_list_map: any = {}; // 所有的在线房间列表 45 | private timerList: any = {}; // 所有的在线房间列表 46 | 47 | /* 连接成功 */ 48 | async handleConnection(client: Socket): Promise { 49 | this.connectSuccess(client, client.handshake.query); 50 | } 51 | 52 | /* 断开连接 */ 53 | async handleDisconnect(client: Socket) { 54 | const clientInfo = this.clientIdMap[client.id]; 55 | if (!clientInfo) return; 56 | /* 删除此用户记录 */ 57 | delete this.clientIdMap[client.id]; 58 | const { user_id, room_id } = clientInfo; 59 | const { on_line_user_list, room_info, room_admin_info } = 60 | this.room_list_map[room_id]; 61 | let user_nick; 62 | /* 找到这个退出的用户并且从在线列表移除 */ 63 | const delUserIndex = on_line_user_list.findIndex((t) => { 64 | if (t.id === user_id) { 65 | user_nick = t.user_nick; 66 | return true; 67 | } 68 | }); 69 | on_line_user_list.splice(delUserIndex, 1); 70 | /* 如果这个用户离开后房间没人了 那么我们关闭这个房间已经定时器 如果还剩人那么通知房间所有人更新在线用户列表 */ 71 | /* 主房间默认888 如果是主房间 没人也不关闭 */ 72 | if (!on_line_user_list.length && Number(room_id) !== 888) { 73 | clearTimeout(this.timerList[`timer${room_id}`]); 74 | delete this.room_list_map[Number(room_id)]; 75 | /* 通知所有人房间列表变更了 */ 76 | const { room_name } = room_info; 77 | const { user_nick: roomAdminNick } = room_admin_info; 78 | return this.socket.emit('updateRoomlist', { 79 | room_list: formatRoomlist(this.room_list_map), 80 | msg: `[${roomAdminNick}]的房间 [${room_name}] 因房间人已全部退出被系统关闭了`, 81 | }); 82 | } 83 | this.socket.to(room_id).emit('offline', { 84 | code: 1, 85 | on_line_user_list: formatOnlineUser(on_line_user_list, user_id), 86 | msg: `[${user_nick}]离开房间了`, 87 | }); 88 | } 89 | 90 | /* 接收到客户端的消息 */ 91 | @SubscribeMessage('message') 92 | async handleMessage(client: Socket, data: any) { 93 | const { user_id, room_id } = this.clientIdMap[client.id]; 94 | const { message_type, message_content, quote_message = {} } = data; 95 | /* 引用消息数据整理 */ 96 | const { 97 | id: quote_message_id, 98 | message_content: quote_message_content, 99 | message_type: quote_message_type, 100 | user_info: quoteUserInfo = {}, 101 | } = quote_message; 102 | const { id: quote_user_id, user_nick: quote_user_nick } = quoteUserInfo; 103 | 104 | /* 发送的消息数据处理 */ 105 | const { user_nick, user_avatar, user_role, id } = 106 | await this.getUserInfoForClientId(client.id); 107 | const params = { 108 | user_id, 109 | message_content, 110 | message_type, 111 | room_id, 112 | quote_user_id, 113 | quote_message_id, 114 | }; 115 | const message = await this.MessageModel.save(params); 116 | 117 | /* 需要对消息的message_content序列化因为发送的所有消息都是JSON.strify的 */ 118 | message.message_content && 119 | (message.message_content = JSON.parse(message.message_content)); 120 | /* 创建消息之后的信息里没有发送人信息和引用信息,需要自己从客户端带来的信息组装 */ 121 | const result: any = { 122 | ...message, 123 | user_info: { user_nick, user_avatar, user_role, id, user_id: id }, 124 | }; 125 | /* 如果有引用消息,自己组装引用消息需要的数据格式,就不用再请求一次了 */ 126 | quote_user_id && 127 | (result.quote_info = { 128 | quote_user_nick, 129 | quote_message_content, 130 | quote_message_type, 131 | quote_message_id, 132 | quote_message_status: 1, 133 | quote_user_id, 134 | }); 135 | 136 | this.socket 137 | .to(room_id) 138 | .emit('message', { data: result, msg: '有一条新消息' }); 139 | } 140 | 141 | /** 142 | * @desc 客户端发起切歌的请求 判断权限 是否有权切换 143 | * @param client socket 144 | */ 145 | @SubscribeMessage('cutMusic') 146 | async handleCutMusic(client: Socket, music: any) { 147 | const { music_name, music_singer, choose_user_id } = music; 148 | const { room_id } = this.clientIdMap[client.id]; 149 | const { 150 | user_role, 151 | user_nick, 152 | id: user_id, 153 | } = await this.getUserInfoForClientId(client.id); 154 | const { room_admin_info } = this.room_list_map[room_id]; 155 | if ( 156 | !['admin'].includes(user_role) && 157 | user_id !== room_admin_info.id && 158 | user_id !== choose_user_id 159 | ) { 160 | return client.emit('tips', { 161 | code: -1, 162 | msg: '非管理员或房主只能切换自己歌曲哟...', 163 | }); 164 | } 165 | await this.messageNotice(room_id, { 166 | code: 2, 167 | message_type: 'info', 168 | message_content: `${user_nick} 切掉了 ${music_singer}的[${music_name}]`, 169 | }); 170 | this.switchMusic(room_id); 171 | } 172 | 173 | /* 点歌操作 */ 174 | @SubscribeMessage('chooseMusic') 175 | async handlerChooseMusic(client: Socket, musicInfo: any) { 176 | const { user_id, room_id } = this.clientIdMap[client.id]; 177 | const user_info: any = await this.getUserInfoForClientId(client.id); 178 | const { music_name, music_singer, music_mid } = musicInfo; 179 | const { music_queue_list, room_admin_info } = 180 | this.room_list_map[this.clientIdMap[client.id].room_id]; 181 | const { id: room_admin_id } = room_admin_info; 182 | if (music_queue_list.some((t) => t.music_mid === music_mid)) { 183 | return client.emit('tips', { code: -1, msg: '这首歌已经在列表中啦!' }); 184 | } 185 | /* 计算距离上次点歌时间 管理员或者房主 不限制点歌时间 */ 186 | if (this.chooseMusicTimeSpace[user_id]) { 187 | const timeDifference = getTimeSpace(this.chooseMusicTimeSpace[user_id]); 188 | if ( 189 | timeDifference <= 8 && 190 | !['super', 'guest', 'admin'].includes(user_info.user_role) && 191 | user_id !== room_admin_id 192 | ) { 193 | return client.emit('tips', { 194 | code: -1, 195 | msg: `频率过高 请在${8 - timeDifference}秒后重试`, 196 | }); 197 | } 198 | } 199 | musicInfo.user_info = user_info; 200 | music_queue_list.push(musicInfo); 201 | this.chooseMusicTimeSpace[user_id] = getTimeSpace(); 202 | client.emit('tips', { code: 1, msg: '恭喜您点歌成功' }); 203 | this.socket.to(room_id).emit('chooseMusic', { 204 | code: 1, 205 | music_queue_list: music_queue_list, 206 | msg: `${user_info.user_nick} 点了一首 ${music_name}(${music_singer})`, 207 | }); 208 | } 209 | 210 | /** 211 | * @desc 管理员可以移除任何人歌曲 房主可以移除自己房间的任何歌曲 普通用户只能移除自己的 212 | * @param client 213 | * @param music 214 | * @returns 215 | */ 216 | @SubscribeMessage('removeQueueMusic') 217 | async handlerRemoveQueueMusic(client: Socket, music: any) { 218 | const { user_id, room_id } = this.clientIdMap[client.id]; // 房间信息 219 | const { music_mid, music_name, music_singer, user_info } = music; // 当前操作的歌曲信息 220 | const { user_role, id } = user_info; // 点歌人信息 221 | const { music_queue_list, room_admin_info } = this.room_list_map[room_id]; 222 | const { id: room_admin_id } = room_admin_info; // 房主信息 223 | if ( 224 | !['admin'].includes(user_role) && 225 | user_id !== id && 226 | user_id !== room_admin_id 227 | ) { 228 | return client.emit('tips', { 229 | code: -1, 230 | msg: '非管理员或房主只能移除掉自己点的歌曲哟...', 231 | }); 232 | } 233 | const delIndex = music_queue_list.findIndex( 234 | (t) => t.music_mid === music_mid, 235 | ); 236 | music_queue_list.splice(delIndex, 1); 237 | client.emit('tips', { 238 | code: 1, 239 | msg: `成功移除了歌单中的 ${music_name}(${music_singer})`, 240 | }); 241 | this.socket.emit('chooseMusic', { 242 | code: 1, 243 | music_queue_list: music_queue_list, 244 | msg: `${user_info.user_nick} 移除了歌单中的 ${music_name}(${music_singer})`, 245 | }); 246 | } 247 | 248 | /** 249 | * @desc 用户在客户端修改休息后应该通知房间变更用户信息,否则新的聊天的头像名称依然是老的用户信息 250 | * @param client 251 | * @param newUserInfo 新的用户信息,客户端上传来就不用重新查询一次 252 | */ 253 | @SubscribeMessage('updateRoomUserInfo') 254 | async handlerUpdateRoomUserInfo(client: Socket, newUserInfo) { 255 | const { room_id } = this.clientIdMap[client.id]; 256 | const old_user_info = await this.getUserInfoForClientId(client.id); 257 | /* 引用数据类型直接覆盖就可以改变原数据 */ 258 | Object.keys(newUserInfo).forEach( 259 | (key) => (old_user_info[key] = newUserInfo[key]), 260 | ); 261 | 262 | /* 拿到新的当前房间的在线用户列表,通知用户更新,在线列表信息也变了 */ 263 | const { on_line_user_list } = this.room_list_map[Number(room_id)]; 264 | this.socket.to(room_id).emit('updateOnLineUserList', { on_line_user_list }); 265 | } 266 | 267 | /** 268 | * @desc 房主修改完房间资料后需要通知全部人修改房间信息,我们需要变更房间信息 并通知用户修改在线房间列表 269 | * @param client 270 | * @param newRoomInfo 新的房间信息,客户端上传来就不用重新查询一次 271 | */ 272 | @SubscribeMessage('updateRoomInfo') 273 | async handlerUpdateRoomInfo(client: Socket, newRoomInfo) { 274 | const { room_id } = this.clientIdMap[client.id]; 275 | this.room_list_map[Number(room_id)].room_info = newRoomInfo; 276 | const { user_nick } = await this.getUserInfoForClientId(client.id); 277 | const data: any = { 278 | room_list: formatRoomlist(this.room_list_map), 279 | msg: `房主 [${user_nick}] 修改了房间信息`, 280 | }; 281 | this.socket.to(room_id).emit('updateRoomlist', data); 282 | } 283 | 284 | /** 285 | * @desc 客户端撤回消息 286 | * @param client 287 | * @param newUserInfo 288 | */ 289 | @SubscribeMessage('recallMessage') 290 | async handlerRecallMessage(client: Socket, { user_nick, id }) { 291 | const { user_id, room_id } = this.clientIdMap[client.id]; 292 | const message = await this.MessageModel.findOne({ where: { id, user_id } }); 293 | if (!message) 294 | return client.emit('tips', { 295 | code: -1, 296 | msg: '非法操作,不可移除他人消息!', 297 | }); 298 | const { createdAt } = message; 299 | const timeSpace = new Date(createdAt).getTime(); 300 | const now = new Date().getTime(); 301 | if (now - timeSpace > 2 * 60 * 1000) 302 | return client.emit('tips', { code: -1, msg: '只能撤回两分钟内的消息!' }); 303 | await this.MessageModel.update({ id }, { message_status: -1 }); 304 | this.socket.to(room_id).emit('recallMessage', { 305 | code: 1, 306 | id, 307 | msg: `${user_nick} 撤回了一条消息`, 308 | }); 309 | } 310 | 311 | /* >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 下面是方法、不属于客户端提交的事件 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< */ 312 | 313 | /** 314 | * @desc 切换房间歌曲 315 | * @param room_id 房间id 316 | * @returns 317 | */ 318 | async switchMusic(room_id) { 319 | /* 获取下一首的歌曲id */ 320 | const music: any = await this.getNextMusicMid(room_id); 321 | if (!music) { 322 | return this.messageNotice(room_id, { 323 | code: -1, 324 | message_type: 'info', 325 | message_content: '当前房间没有曲库,请自定义点歌吧!', 326 | }); 327 | } 328 | const { mid, user_info, music_queue_list } = music; 329 | try { 330 | /* 获取歌曲详细信息 */ 331 | const { music_lrc, music_info } = await getMusicDetail(mid); 332 | /* 如果有点歌人信息,携带其id,没有标为-1系统随机点播的,切歌时用于判断是否是本人操作 */ 333 | music_info.choose_user_id = user_info ? user_info.id : -1; 334 | /* 获取歌曲远程地址 */ 335 | const music_src = await getMusicSrc(mid); 336 | this.room_list_map[Number(room_id)].music_info = music_info; 337 | this.room_list_map[Number(room_id)].music_lrc = music_lrc; 338 | this.room_list_map[Number(room_id)].music_src = music_src; 339 | const { music_singer, music_album } = music_info; 340 | /* 如果房间点歌队列存在歌曲那么移除房间歌曲列表第一首 */ 341 | music_queue_list.length && 342 | this.room_list_map[Number(room_id)].music_queue_list.shift(); 343 | /* 通知客户端事件切换歌曲 */ 344 | this.socket.to(room_id).emit('switchMusic', { 345 | musicInfo: { music_info, music_src, music_lrc, music_queue_list }, 346 | msg: `正在播放${ 347 | user_info ? user_info.user_nick : '系统随机' 348 | }点播的 ${music_album}(${music_singer})`, 349 | }); 350 | const { music_duration } = music_info; 351 | clearTimeout(this.timerList[`timer${room_id}`]); 352 | /* 设置一个定时器 以歌曲时长为准 歌曲到时间后自动切歌 */ 353 | this.timerList[`timer${room_id}`] = setTimeout(() => { 354 | this.switchMusic(room_id); 355 | }, music_duration * 1000); 356 | /* 拿到歌曲时长, 记录歌曲结束时间, 新用户进入时,可以计算出歌曲还有多久结束 */ 357 | this.room_list_map[Number(room_id)].last_music_timespace = 358 | new Date().getTime() + music_duration * 1000; 359 | } catch (error) { 360 | /* 如果拿的mid查询歌曲出错了 说明这个歌曲已经不能播放量 切换下一首 并且移除这首歌曲 */ 361 | this.MusicModel.delete({ music_mid: mid }); 362 | music_queue_list.shift(); 363 | this.switchMusic(room_id); 364 | return this.messageNotice(room_id, { 365 | code: 2, 366 | message_type: 'info', 367 | message_content: `当前歌曲 (${music_queue_list[0]?.music_name}) 为付费内容,请下载酷我音乐客户端后付费收听!`, 368 | }); 369 | } 370 | } 371 | 372 | /* 获取下一首音乐id、有人点歌拿到歌单中的mid 没有则去db随机一首 */ 373 | async getNextMusicMid(room_id) { 374 | let mid: any; 375 | let user_info: any = null; 376 | let music_queue_list: any = []; 377 | this.room_list_map[Number(room_id)] && 378 | (music_queue_list = this.room_list_map[Number(room_id)].music_queue_list); 379 | 380 | /* 如果当前房间有点歌列表,就顺延,没有就随机播放一区 */ 381 | if (music_queue_list.length) { 382 | mid = music_queue_list[0].music_mid; 383 | user_info = music_queue_list[0]?.user_info; 384 | } else { 385 | const count = await this.MusicModel.count(); 386 | const randomIndex = Math.floor(Math.random() * count); 387 | const music: any = await this.MusicModel.find({ 388 | take: 1, 389 | skip: randomIndex, 390 | }); 391 | const random_music = music[0]; 392 | /* TODO 如果删除了db 可能导致这个随机id查不到数据,要保证不要删除tb_music的数据 或者自定义id用于随机歌曲查询 或增加一个随机歌曲的爬虫方法 */ 393 | if (!random_music) { 394 | return; 395 | } 396 | mid = random_music?.music_mid; 397 | } 398 | return { mid, user_info, music_queue_list }; 399 | } 400 | 401 | /** 402 | * @desc 初次加入房间 403 | * @param client ws 404 | * @param query 加入房间携带了token和位置信息 405 | * @returns 406 | */ 407 | async connectSuccess(client, query) { 408 | try { 409 | const { token, address, room_id = 888 } = query; 410 | const payload = await verifyToken(token); 411 | const { user_id } = payload; 412 | /* token校验 */ 413 | if (user_id === -1 || !token) { 414 | client.emit('authFail', { code: -1, msg: '权限校验失败,请重新登录' }); 415 | return client.disconnect(); 416 | } 417 | 418 | /* 判断这个用户是不是在连接状态中 */ 419 | Object.keys(this.clientIdMap).forEach((clientId) => { 420 | if (this.clientIdMap[clientId]['user_id'] === user_id) { 421 | /* 提示老的用户被挤掉 */ 422 | this.socket.to(clientId).emit('tips', { 423 | code: -2, 424 | msg: '您的账户在别地登录了,您已被迫下线', 425 | }); 426 | /* 提示新用户是覆盖登录 */ 427 | client.emit('tips', { 428 | code: -1, 429 | msg: '您的账户已在别地登录,已为您覆盖登录!', 430 | }); 431 | /* 断开老的用户连接 并移除掉老用户的记录 */ 432 | this.socket.in(clientId).disconnectSockets(true); 433 | delete this.clientIdMap[clientId]; 434 | } 435 | }); 436 | 437 | /* 判断用户是不是已经在房间里面了 */ 438 | if ( 439 | Object.values(this.room_list_map).some((t: any) => 440 | t.on_line_user_list.includes(user_id), 441 | ) 442 | ) { 443 | return client.emit('tips', { code: -2, msg: '您已经在别处登录了' }); 444 | } 445 | /* 查询用户基础信息 */ 446 | const u = await this.UserModel.findOne({ where: { id: user_id } }); 447 | const { 448 | user_name, 449 | user_nick, 450 | user_email, 451 | user_sex, 452 | user_role, 453 | user_avatar, 454 | user_sign, 455 | user_room_bg, 456 | id, 457 | } = u; 458 | const userInfo = { 459 | user_name, 460 | user_nick, 461 | user_email, 462 | user_role, 463 | user_avatar, 464 | user_sign, 465 | user_room_bg, 466 | user_sex, 467 | id, 468 | }; 469 | if (!u) { 470 | client.emit('authFail', { code: -1, msg: '无此用户信息、非法操作!' }); 471 | return client.disconnect(); 472 | } 473 | /* 查询房间信息 如果没有当前这个房间id 说明需要新建这个房间 */ 474 | const room_info = await this.RoomModel.findOne({ 475 | where: { room_id }, 476 | select: [ 477 | 'room_id', 478 | 'room_user_id', 479 | 'room_logo', 480 | 'room_name', 481 | 'room_notice', 482 | 'room_bg_img', 483 | 'room_need_password', 484 | ], 485 | }); 486 | if (!room_info) { 487 | client.emit('tips', { 488 | code: -3, 489 | msg: '您正在尝试加入一个不存在的房间、非法操作!!!', 490 | }); 491 | return client.disconnect(); 492 | } 493 | 494 | /* 正式加入房间 */ 495 | client.join(room_id); 496 | 497 | const isHasRoom = this.room_list_map[room_id]; 498 | 499 | /* 判断当前房间列表有没有这个房间,没有就新增到房间列表, 并把用户加入到房间在线列表 */ 500 | !isHasRoom && (await this.initBasicRoomInfo(room_id, room_info)); 501 | this.room_list_map[room_id].on_line_user_list.push(userInfo); 502 | 503 | /* 记录当前连接的clientId用户和房间号的映射关系 */ 504 | this.clientIdMap[client.id] = { user_id, room_id }; 505 | 506 | /* 记录用户到在线列表,并记住当前用户的房间号 */ 507 | this.onlineUserInfo[user_id] = { userInfo, roomId: room_id }; 508 | 509 | /* 初始化房间信息 */ 510 | await this.initRoom(client, user_id, user_nick, address, room_id); 511 | 512 | /* 需要通知别的所有人更新房间列表,如果是房间可以加一句提示消息告知开启了新房间 */ 513 | const data: any = { room_list: formatRoomlist(this.room_list_map) }; 514 | !isHasRoom && 515 | (data.msg = `${user_nick}的房间[${room_info.room_name}]有新用户加入已成功开启`); 516 | this.socket.emit('updateRoomlist', data); 517 | } catch (error) {} 518 | } 519 | 520 | /** 521 | * @desc 加入房间之后初始化信息 包含个人信息,歌曲列表,当前播放时间等等 522 | * @param client 523 | * @param user_id 524 | * @param user_nick 525 | */ 526 | async initRoom(client, user_id, user_nick, address, room_id) { 527 | const { 528 | music_info, 529 | music_queue_list, 530 | music_src, 531 | music_lrc, 532 | on_line_user_list, 533 | last_music_timespace, 534 | room_admin_info, 535 | } = this.room_list_map[Number(room_id)]; 536 | const music_start_time = 537 | music_info.music_duration - 538 | Math.round((last_music_timespace - new Date().getTime()) / 1000); 539 | const formatOnlineUserList = formatOnlineUser( 540 | on_line_user_list, 541 | room_admin_info.id, 542 | ); 543 | /* 初始化房间用户需要用到的各种信息 */ 544 | await client.emit('initRoom', { 545 | user_id, 546 | music_src, 547 | music_info, 548 | music_lrc, 549 | music_start_time, 550 | music_queue_list, 551 | on_line_user_list: formatOnlineUserList, 552 | room_admin_info, 553 | room_list: formatRoomlist(this.room_list_map), 554 | tips: `欢迎${user_nick}加入房间!`, 555 | msg: `来自${address}的[${user_nick}]进入房间了`, 556 | }); 557 | 558 | /* 新用户上线,通知其他人,并更新房间的在线用户列表 */ 559 | client.broadcast.to(room_id).emit('online', { 560 | on_line_user_list: formatOnlineUserList, 561 | msg: `来自${address}的[${user_nick}]进入房间了`, 562 | }); 563 | } 564 | 565 | /** 566 | * @desc 全局消息类型通知,发送给所有人的消息 567 | * @param message {}: message_type 通知消息类型 message_content 通知内容 568 | * @param room_id 569 | */ 570 | messageNotice(room_id, message) { 571 | this.socket.to(room_id).emit('notice', message); 572 | } 573 | 574 | /** 575 | * @desc 初始化房间信息,并记录房间相关信息 576 | * @param roomId 房间Id 577 | * @param roomInfo 房间信息 db查询的结果 578 | */ 579 | async initBasicRoomInfo(room_id, room_info) { 580 | const { room_user_id } = room_info; 581 | const room_admin_info = await this.UserModel.findOne({ 582 | where: { id: room_user_id }, 583 | select: ['user_nick', 'user_avatar', 'id', 'user_role'], 584 | }); 585 | 586 | this.room_list_map[Number(room_id)] = { 587 | on_line_user_list: [], 588 | music_queue_list: [], 589 | music_info: {}, 590 | last_music_timespace: null, 591 | music_src: null, 592 | music_lrc: null, 593 | [`timer${room_id}`]: null, 594 | room_info, 595 | room_admin_info, 596 | }; 597 | 598 | /* 初次启动房间,需要开始启动音乐 */ 599 | await this.switchMusic(room_id); 600 | } 601 | 602 | /** 603 | * @desc 通过clientId 拿到用户信息 604 | * @param room_id 605 | * @param cliend_id 606 | */ 607 | async getUserInfoForClientId(cliend_id) { 608 | const { user_id, room_id } = this.clientIdMap[cliend_id]; 609 | const { on_line_user_list } = this.room_list_map[room_id]; 610 | return on_line_user_list.find((t) => t.id === user_id); 611 | } 612 | 613 | /** 614 | * @desc 通过clientId 拿到房间歌曲队列 615 | * @param room_id 616 | * @param cliend_id 617 | */ 618 | async getMusicQueueForClientId(cliend_id) { 619 | const { room_id } = this.clientIdMap[cliend_id]; 620 | const { music_queue_list } = this.room_list_map[room_id]; 621 | return music_queue_list; 622 | } 623 | } 624 | --------------------------------------------------------------------------------