├── tsconfig-paths-bootstrap.cjs ├── src ├── utils │ ├── index.ts │ ├── suffixGenerator.ts │ ├── shuffleArray.ts │ ├── downloadFile.ts │ └── password.ts ├── modules │ ├── user │ │ ├── dto │ │ │ ├── index.ts │ │ │ ├── login-user.dto.ts │ │ │ ├── register-user.dto.ts │ │ │ └── update-user.dto.ts │ │ ├── vo │ │ │ ├── user-item.vo.ts │ │ │ ├── login.vo.ts │ │ │ └── detail.vo.ts │ │ ├── entities │ │ │ ├── follow.entity.ts │ │ │ ├── like-works.entity.ts │ │ │ └── user.entity.ts │ │ ├── user.module.ts │ │ ├── user.controller.ts │ │ └── user.service.ts │ ├── favorite │ │ ├── dto │ │ │ ├── change-order.dto.ts │ │ │ ├── edit-favorite.dto.ts │ │ │ └── create-favorite.dto.ts │ │ ├── vo │ │ │ ├── favorite-item.vo.ts │ │ │ └── favorite-detail.vo.ts │ │ ├── favorite.module.ts │ │ ├── entities │ │ │ ├── collect-record.entity.ts │ │ │ └── favorite.entity.ts │ │ ├── favorite.controller.ts │ │ └── favorite.service.ts │ ├── label │ │ ├── dto │ │ │ └── new-label.dto.ts │ │ ├── vo │ │ │ ├── label-item.vo.ts │ │ │ └── label-detail.vo.ts │ │ ├── label.module.ts │ │ ├── entities │ │ │ └── label.entity.ts │ │ ├── label.controller.ts │ │ └── label.service.ts │ ├── comment │ │ ├── dto │ │ │ └── create-comment.dto.ts │ │ ├── comment.module.ts │ │ ├── comment.controller.ts │ │ ├── vo │ │ │ └── comment-item.vo.ts │ │ ├── entities │ │ │ └── comment.entity.ts │ │ └── comment.service.ts │ ├── history │ │ ├── history.module.ts │ │ ├── vo │ │ │ └── history-item.vo.ts │ │ ├── entities │ │ │ └── history.entity.ts │ │ ├── history.controller.ts │ │ └── history.service.ts │ ├── illustrator │ │ ├── dto │ │ │ ├── edit-illustrator.dto.ts │ │ │ └── new-illustrator.dto.ts │ │ ├── illustrator.module.ts │ │ ├── vo │ │ │ └── illustrator-detail.vo.ts │ │ ├── entities │ │ │ └── illustrator.entity.ts │ │ ├── illustrator.controller.ts │ │ └── illustrator.service.ts │ └── illustration │ │ ├── vo │ │ ├── illustration-item.vo.ts │ │ └── illustration-detail.vo.ts │ │ ├── entities │ │ ├── work-push-temp.entity.ts │ │ ├── image.entity.ts │ │ └── illustration.entity.ts │ │ ├── illustration.module.ts │ │ ├── dto │ │ └── upload-illustration.dto.ts │ │ ├── illustration.controller.ts │ │ └── illustration.service.ts ├── infra │ ├── config │ │ └── config.module.ts │ ├── email │ │ ├── email.module.ts │ │ └── email.service.ts │ ├── r2 │ │ ├── r2.module.ts │ │ ├── r2.service.ts │ │ └── r2.controller.ts │ ├── jwt │ │ └── jwt.module.ts │ ├── redis │ │ └── redis.module.ts │ └── database │ │ └── database.module.ts ├── types │ └── index.d.ts ├── services │ ├── img-handler │ │ ├── img-handler.module.ts │ │ ├── img-handler.controller.ts │ │ └── img-handler.service.ts │ └── scripts │ │ ├── scripts.module.ts │ │ └── scripts.service.ts ├── common │ ├── error │ │ ├── hanaError.ts │ │ ├── error.filter.ts │ │ └── errorList.ts │ ├── interceptors │ │ ├── response.interceptor.ts │ │ ├── multiple-imgs.interceptor.ts │ │ ├── single-img.interceptor.ts │ │ └── invoke-record.interceptor.ts │ ├── decorators │ │ └── login.decorator.ts │ └── guards │ │ └── auth.guard.ts ├── main.ts └── app.module.ts ├── scripts ├── copy.ts ├── seed-data.ts ├── upload-dir.ts └── password.ts ├── tsconfig.build.json ├── .prettierrc ├── nest-cli.json ├── commands └── setup.sh ├── ecosystem.config.cjs ├── .vscode └── settings.json ├── .gitignore ├── tsconfig.json ├── .env.example ├── eslint.config.js ├── package.json └── README.md /tsconfig-paths-bootstrap.cjs: -------------------------------------------------------------------------------- 1 | require('tsconfig-paths/register'); 2 | require('ts-node/register'); 3 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './password'; 2 | export * from './downloadFile'; 3 | export * from './suffixGenerator'; 4 | -------------------------------------------------------------------------------- /scripts/copy.ts: -------------------------------------------------------------------------------- 1 | import * as shelljs from 'shelljs'; 2 | 3 | shelljs.cp('-R', '.env', 'dist'); 4 | shelljs.cp('-R', 'uploads', 'dist'); 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "copy.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/user/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './register-user.dto'; 2 | export * from './login-user.dto'; 3 | export * from './update-user.dto'; 4 | -------------------------------------------------------------------------------- /src/utils/suffixGenerator.ts: -------------------------------------------------------------------------------- 1 | export const suffixGenerator = (origin: string) => 2 | Date.now() + '-' + Math.round(Math.random() * 1e9) + '-' + origin; 3 | -------------------------------------------------------------------------------- /src/infra/config/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule as configModule } from '@nestjs/config'; 3 | 4 | @Module({ 5 | imports: [configModule.forRoot({ isGlobal: true })], 6 | }) 7 | export class ConfigModule {} 8 | -------------------------------------------------------------------------------- /src/modules/favorite/dto/change-order.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | 3 | export class ChangeOrderDto { 4 | @IsNotEmpty({ 5 | message: '收藏夹排序列表不能为空', 6 | }) 7 | orderList: { 8 | id: string; 9 | order: number; 10 | }[]; 11 | } 12 | -------------------------------------------------------------------------------- /src/infra/email/email.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { EmailService } from './email.service'; 3 | 4 | @Global() 5 | @Module({ 6 | providers: [EmailService], 7 | exports: [EmailService], 8 | }) 9 | export class EmailModule {} 10 | -------------------------------------------------------------------------------- /src/modules/label/dto/new-label.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, Length } from 'class-validator'; 2 | 3 | export class NewLabelDto { 4 | @IsNotEmpty({ 5 | message: '标签名不能为空', 6 | }) 7 | @Length(1, 31, { 8 | message: '标签名长度必须在1到31之间', 9 | }) 10 | value: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Request } from 'express'; 2 | import type { JwtUserData } from '@/common/guards/auth.guard'; 3 | 4 | export type DEVICES_TYPE = 'mobile' | 'desktop'; 5 | 6 | export interface AuthenticatedRequest extends Request { 7 | user?: JwtUserData; 8 | } 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 100, 5 | "tabWidth": 2, 6 | "useTabs": true, 7 | "semi": true, 8 | "quoteProps": "as-needed", 9 | "bracketSpacing": true, 10 | "arrowParens": "always", 11 | "proseWrap": "preserve" 12 | } 13 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | }, 8 | "generateOptions": { 9 | "spec": false, 10 | "flat": false 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/shuffleArray.ts: -------------------------------------------------------------------------------- 1 | export const shuffleArray = (array: T[]): T[] => { 2 | const newArr = [...array]; 3 | for (let i = newArr.length - 1; i > 0; i--) { 4 | const j = Math.floor(Math.random() * (i + 1)); 5 | [newArr[i], newArr[j]] = [newArr[j], newArr[i]]; // Swap 6 | } 7 | return newArr; 8 | }; 9 | -------------------------------------------------------------------------------- /commands/setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | #image_version=`date +%Y%m%d%H%M`; 3 | 4 | # 关闭容器 5 | docker-compose stop || true; 6 | # 删除容器 7 | docker-compose down || true; 8 | # 构建镜像 9 | docker-compose build; 10 | # 启动并后台运行 11 | docker-compose up -d; 12 | # 查看日志 13 | docker logs nest-app; 14 | # 对空间进行自动清理 15 | docker system prune -a -f -------------------------------------------------------------------------------- /src/infra/r2/r2.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { R2Service } from './r2.service'; 3 | import { R2Controller } from './r2.controller'; 4 | 5 | @Global() 6 | @Module({ 7 | providers: [R2Service], 8 | exports: [R2Service], 9 | controllers: [R2Controller], 10 | }) 11 | export class R2Module {} 12 | -------------------------------------------------------------------------------- /src/utils/downloadFile.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | /** 4 | * 从 url 下载文件,返回二进制数据 5 | * @param url - 文件的 URL 6 | * @returns 文件的二进制数据 7 | */ 8 | export const downloadFile = async (url: string): Promise => { 9 | const response = await axios.get(url, { responseType: 'arraybuffer' }); 10 | return response.data; 11 | }; 12 | -------------------------------------------------------------------------------- /ecosystem.config.cjs: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | 3 | module.exports = { 4 | apps: [ 5 | { 6 | name: 'picals-backend', 7 | port: 6379, 8 | cwd: path.join(__dirname), 9 | instances: 1, 10 | autorestart: true, 11 | watch: false, 12 | max_memory_restart: '1G', 13 | script: './dist/src/main.js', 14 | }, 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /src/utils/password.ts: -------------------------------------------------------------------------------- 1 | import * as argon2 from 'argon2'; 2 | 3 | // 加密密码 4 | export async function hashPassword(password: string): Promise { 5 | return await argon2.hash(password); 6 | } 7 | 8 | // 验证密码 9 | export async function verifyPassword(password: string, hashedPassword: string): Promise { 10 | return await argon2.verify(hashedPassword, password); 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/label/vo/label-item.vo.ts: -------------------------------------------------------------------------------- 1 | import type { Label } from '@/modules/label/entities/label.entity'; 2 | 3 | export class LabelItemVO { 4 | id: string; 5 | name: string; 6 | cover: string | null; 7 | color: string; 8 | 9 | constructor(label: Label) { 10 | this.id = label.id; 11 | this.name = label.value; 12 | this.cover = label.cover; 13 | this.color = label.color; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/services/img-handler/img-handler.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { ImgHandlerService } from './img-handler.service'; 3 | import { ImgHandlerController } from './img-handler.controller'; 4 | 5 | @Global() 6 | @Module({ 7 | exports: [ImgHandlerService], 8 | providers: [ImgHandlerService], 9 | controllers: [ImgHandlerController], 10 | }) 11 | export class ImgHandlerModule {} 12 | -------------------------------------------------------------------------------- /src/common/error/hanaError.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | import { errorMessages } from './errorList'; 3 | 4 | class hanaError extends HttpException { 5 | constructor(code: number, message?: string) { 6 | super( 7 | { 8 | code, 9 | message: message || errorMessages.get(code) || 'Unknown Error', 10 | }, 11 | HttpStatus.BAD_REQUEST, 12 | ); 13 | } 14 | } 15 | 16 | export { hanaError }; 17 | -------------------------------------------------------------------------------- /src/modules/comment/dto/create-comment.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, Length, IsOptional } from 'class-validator'; 2 | 3 | export class CreateCommentDto { 4 | @IsNotEmpty({ 5 | message: '作品id不能为空', 6 | }) 7 | id: string; 8 | 9 | @IsNotEmpty({ 10 | message: '评论内容不能为空', 11 | }) 12 | @Length(1, 2047, { 13 | message: '评论内容长度不能大于2047', 14 | }) 15 | content: string; 16 | 17 | @IsOptional() 18 | replyInfo?: { 19 | replyCommentId: string; 20 | replyUserId?: string; 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/history/history.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { HistoryService } from './history.service'; 3 | import { HistoryController } from './history.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { History } from './entities/history.entity'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([History])], 9 | controllers: [HistoryController], 10 | providers: [HistoryService], 11 | }) 12 | export class HistoryModule {} 13 | -------------------------------------------------------------------------------- /src/modules/favorite/dto/edit-favorite.dto.ts: -------------------------------------------------------------------------------- 1 | import { Length, IsOptional, Matches } from 'class-validator'; 2 | 3 | export class EditFavoriteDto { 4 | @IsOptional() 5 | @Length(1, 31, { 6 | message: '收藏夹名称长度不能大于31', 7 | }) 8 | name?: string; 9 | 10 | @IsOptional() 11 | @Length(1, 255, { 12 | message: '收藏夹简介长度不能大于255', 13 | }) 14 | intro?: string; 15 | 16 | @IsOptional() 17 | @Matches(/(https?:\/\/.*\.(?:png|jpg))/, { 18 | message: '图片格式不正确,需要以http或https开头、以png或jpg结尾', 19 | }) 20 | cover?: string; 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "biome.enabled": false, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": "explicit", 7 | "source.organizeImports": "never" 8 | }, 9 | "[typescript]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | }, 12 | "[javascript]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | }, 15 | "eslint.validate": ["javascript", "typescript", "javascriptreact", "typescriptreact"] 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/favorite/vo/favorite-item.vo.ts: -------------------------------------------------------------------------------- 1 | import type { Favorite } from '@/modules/favorite/entities/favorite.entity'; 2 | 3 | export class FavoriteItemVo { 4 | cover: null | string; 5 | id: string; 6 | intro: string; 7 | name: string; 8 | order: number; 9 | workNum: number; 10 | 11 | constructor(favorite: Favorite) { 12 | this.cover = favorite.cover; 13 | this.id = favorite.id; 14 | this.intro = favorite.introduce; 15 | this.name = favorite.name; 16 | this.order = favorite.order; 17 | this.workNum = favorite.workCount; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /scripts/seed-data.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { ScriptsModule } from '@/services/scripts/scripts.module'; 3 | import { ScriptsService } from '@/services/scripts/scripts.service'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.createApplicationContext(ScriptsModule); 7 | const ScriptService = app.get(ScriptsService); 8 | 9 | await ScriptService.mock(); 10 | await app.close(); 11 | } 12 | 13 | bootstrap().catch((err) => { 14 | console.error('Error seeding test data:', err); 15 | process.exit(1); 16 | }); 17 | -------------------------------------------------------------------------------- /src/modules/comment/comment.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CommentService } from './comment.service'; 3 | import { CommentController } from './comment.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Comment } from './entities/comment.entity'; 6 | import { IllustrationModule } from '../illustration/illustration.module'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([Comment]), IllustrationModule], 10 | controllers: [CommentController], 11 | providers: [CommentService], 12 | }) 13 | export class CommentModule {} 14 | -------------------------------------------------------------------------------- /src/infra/jwt/jwt.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule as jwtModule } from '@nestjs/jwt'; 3 | import { ConfigService } from '@nestjs/config'; 4 | 5 | @Module({ 6 | imports: [ 7 | jwtModule.registerAsync({ 8 | global: true, 9 | useFactory(configService: ConfigService) { 10 | return { 11 | secret: configService.get('JWT_SECRET'), 12 | signOptions: { 13 | expiresIn: configService.get('JWT_ACCESS_TOKEN_EXPIRES_TIME'), 14 | }, 15 | }; 16 | }, 17 | inject: [ConfigService], 18 | }), 19 | ], 20 | }) 21 | export class JwtModule {} 22 | -------------------------------------------------------------------------------- /src/services/img-handler/img-handler.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Inject, Post } from '@nestjs/common'; 2 | import { ImgHandlerService } from './img-handler.service'; 3 | 4 | @Controller('img-handler') 5 | export class ImgHandlerController { 6 | @Inject(ImgHandlerService) 7 | private readonly imgHandlerService: ImgHandlerService; 8 | 9 | @Post('generate-thumbnail') 10 | async generateThumbnail( 11 | @Body('imageBuffer') imageBuffer: Buffer, 12 | @Body('fileName') fileName: string, 13 | ) { 14 | return await this.imgHandlerService.generateThumbnail(imageBuffer, fileName); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/favorite/dto/create-favorite.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, Length, IsOptional, Matches } from 'class-validator'; 2 | 3 | export class CreateFavoriteDto { 4 | @IsNotEmpty({ 5 | message: '收藏夹名称不能为空', 6 | }) 7 | @Length(1, 31, { 8 | message: '收藏夹名称长度不能大于31', 9 | }) 10 | name: string; 11 | 12 | @IsNotEmpty({ 13 | message: '收藏夹简介不能为空', 14 | }) 15 | @Length(1, 255, { 16 | message: '收藏夹简介长度不能大于255', 17 | }) 18 | intro: string; 19 | 20 | @IsOptional() 21 | @Matches(/(https?:\/\/.*\.(?:png|jpg))/, { 22 | message: '图片格式不正确,需要以http或https开头、以png或jpg结尾', 23 | }) 24 | cover?: string; 25 | } 26 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ConfigService } from '@nestjs/config'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | app.setGlobalPrefix('api'); 8 | const configConfig = app.get(ConfigService); 9 | app.enableCors({ 10 | origin: configConfig.get('CORS_ORIGIN'), 11 | methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], 12 | allowedHeaders: ['Content-Type', 'Authorization'], 13 | credentials: true, 14 | }); 15 | 16 | await app.listen(configConfig.get('NEST_PORT')); 17 | } 18 | bootstrap(); 19 | -------------------------------------------------------------------------------- /src/modules/favorite/vo/favorite-detail.vo.ts: -------------------------------------------------------------------------------- 1 | import type { Favorite } from '../entities/favorite.entity'; 2 | 3 | export class FavoriteDetailVo { 4 | id: string; 5 | cover: null | string; 6 | creatorId: string; 7 | creatorName: string; 8 | intro: string; 9 | name: string; 10 | workNum: number; 11 | 12 | constructor(favorite: Favorite) { 13 | this.id = favorite.id; 14 | this.name = favorite.name; 15 | this.intro = favorite.introduce; 16 | this.cover = favorite.cover; 17 | this.creatorId = favorite.user.id; 18 | this.creatorName = favorite.user.username; 19 | this.workNum = favorite.workCount; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /scripts/upload-dir.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { ScriptsModule } from '@/services/scripts/scripts.module'; 3 | import { ScriptsService } from '@/services/scripts/scripts.service'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.createApplicationContext(ScriptsModule); 7 | const scriptsService = app.get(ScriptsService); 8 | 9 | const [uploadPath, email] = process.argv.slice(2); 10 | 11 | await scriptsService.uploadDir(uploadPath, email); 12 | await app.close(); 13 | } 14 | 15 | bootstrap().catch((err) => { 16 | console.error('Error seeding test data:', err); 17 | process.exit(1); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/illustrator/dto/edit-illustrator.dto.ts: -------------------------------------------------------------------------------- 1 | import { Length, MaxLength, Matches, IsOptional } from 'class-validator'; 2 | 3 | export class EditIllustratorDto { 4 | @IsOptional() 5 | @Matches(/(https?:\/\/.*)/, { 6 | message: '主页地址格式不正确,需要以http或https开头', 7 | }) 8 | homeUrl?: string; 9 | 10 | @IsOptional() 11 | @Length(1, 31, { 12 | message: '名字长度不能大于31', 13 | }) 14 | name?: string; 15 | 16 | @IsOptional() 17 | @Matches(/(https?:\/\/.*\.(?:png|jpg))/, { 18 | message: '图片格式不正确,需要以http或https开头、以png或jpg结尾', 19 | }) 20 | avatar?: string; 21 | 22 | @IsOptional() 23 | @MaxLength(255, { 24 | message: '简介长度不能大于255', 25 | }) 26 | intro?: string; 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/label/label.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { LabelService } from './label.service'; 3 | import { LabelController } from './label.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Label } from './entities/label.entity'; 6 | import { Illustration } from '../illustration/entities/illustration.entity'; 7 | import { UserModule } from '../user/user.module'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([Label, Illustration]), forwardRef(() => UserModule)], 11 | controllers: [LabelController], 12 | providers: [LabelService], 13 | exports: [LabelService], 14 | }) 15 | export class LabelModule {} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | # .env file 38 | /.env 39 | 40 | # uploads 41 | /uploads 42 | 43 | # Docker 44 | Dockerfile 45 | docker-compose.yml 46 | .dockerignore -------------------------------------------------------------------------------- /src/modules/user/dto/login-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, Matches, MaxLength, MinLength } from 'class-validator'; 2 | 3 | export class LoginUserDto { 4 | @IsEmail( 5 | { 6 | allow_ip_domain: false, 7 | allow_utf8_local_part: true, 8 | require_tld: true, 9 | }, 10 | { message: '邮箱格式不正确' }, 11 | ) 12 | @IsNotEmpty({ 13 | message: '邮箱不能为空', 14 | }) 15 | email: string; 16 | 17 | @MaxLength(31, { 18 | message: '密码长度不能大于31', 19 | }) 20 | @MinLength(6, { 21 | message: '密码长度不能小于6', 22 | }) 23 | @IsNotEmpty({ 24 | message: '密码不能为空', 25 | }) 26 | @Matches(/^(?=.*[A-Za-z])(?=.*\d)[\s\S]{6,}$/, { 27 | message: '密码必须包含字母和数字', 28 | }) 29 | password: string; 30 | } 31 | -------------------------------------------------------------------------------- /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": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "paths": { 14 | "@/*": ["src/*"] 15 | }, 16 | "incremental": true, 17 | "skipLibCheck": true, 18 | "strictNullChecks": false, 19 | "noImplicitAny": false, 20 | "strictBindCallApply": false, 21 | "forceConsistentCasingInFileNames": false, 22 | "noFallthroughCasesInSwitch": false, 23 | "typeRoots": ["node_modules/@types", "src/types"] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/common/interceptors/response.interceptor.ts: -------------------------------------------------------------------------------- 1 | // src/interceptor/response.interceptor.ts 2 | // 定义用于约束返回类型的响应拦截器 3 | 4 | import { Injectable } from '@nestjs/common'; 5 | import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common'; 6 | import type { Observable } from 'rxjs'; 7 | import { map } from 'rxjs/operators'; 8 | 9 | interface Data { 10 | data: T; 11 | } 12 | 13 | @Injectable() 14 | export class ResponseInterceptor implements NestInterceptor { 15 | intercept(_: ExecutionContext, next: CallHandler): Observable> { 16 | return next.handle().pipe( 17 | map((data) => ({ 18 | code: 200, 19 | message: 'success', 20 | data, 21 | })), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/illustrator/illustrator.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { IllustratorService } from './illustrator.service'; 3 | import { IllustratorController } from './illustrator.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Illustrator } from './entities/illustrator.entity'; 6 | import { Illustration } from '../illustration/entities/illustration.entity'; 7 | import { UserModule } from '../user/user.module'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([Illustrator, Illustration]), UserModule], 11 | controllers: [IllustratorController], 12 | providers: [IllustratorService], 13 | exports: [IllustratorService], 14 | }) 15 | export class IllustratorModule {} 16 | -------------------------------------------------------------------------------- /src/modules/illustrator/vo/illustrator-detail.vo.ts: -------------------------------------------------------------------------------- 1 | import type { Illustrator } from '../entities/illustrator.entity'; 2 | 3 | export class IllustratorDetailVo { 4 | id: string; 5 | avatar: string; 6 | createdAt: string; 7 | intro: string; 8 | name: string; 9 | updatedAt: string; 10 | workNum: number; 11 | homeUrl: string; 12 | 13 | constructor(illustrator: Illustrator) { 14 | this.id = illustrator.id; 15 | this.avatar = illustrator.avatar; 16 | this.createdAt = illustrator.createdTime.toISOString(); 17 | this.intro = illustrator.intro; 18 | this.name = illustrator.name; 19 | this.updatedAt = illustrator.updatedTime.toISOString(); 20 | this.workNum = illustrator.workCount; 21 | this.homeUrl = illustrator.homeUrl; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/modules/illustrator/dto/new-illustrator.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, Length, MaxLength, Matches, IsOptional } from 'class-validator'; 2 | 3 | export class NewIllustratorDto { 4 | @IsNotEmpty({ 5 | message: '主页地址不能为空', 6 | }) 7 | @Matches(/(https?:\/\/.*)/, { 8 | message: '主页地址格式不正确,需要以http或https开头', 9 | }) 10 | homeUrl: string; 11 | 12 | @IsNotEmpty({ 13 | message: '名字不能为空', 14 | }) 15 | @Length(1, 31, { 16 | message: '名字长度不能大于31', 17 | }) 18 | name: string; 19 | 20 | @IsOptional() 21 | @Matches(/(https?:\/\/.*\.(?:png|jpg))/, { 22 | message: '图片格式不正确,需要以http或https开头、以png或jpg结尾', 23 | }) 24 | avatar?: string; 25 | 26 | @IsOptional() 27 | @MaxLength(255, { 28 | message: '简介长度不能大于255', 29 | }) 30 | intro?: string; 31 | } 32 | -------------------------------------------------------------------------------- /src/common/decorators/login.decorator.ts: -------------------------------------------------------------------------------- 1 | import type { ExecutionContext } from '@nestjs/common'; 2 | import { SetMetadata, createParamDecorator } from '@nestjs/common'; 3 | import type { AuthenticatedRequest } from '@/types'; 4 | 5 | // RequireLogin 装饰器,用于指定哪个接口需要登录 6 | export const RequireLogin = () => SetMetadata('require-login', true); 7 | 8 | // AllowVisitor 装饰器,用于指定哪个接口可以不需要登录就可以访问。并且如果有登录,会照常解析用户信息 9 | export const AllowVisitor = () => SetMetadata('visitor', true); 10 | 11 | // UserInfo 装饰器,用于从请求中获取用户信息(jwt解析) 12 | export const UserInfo = createParamDecorator((data: string, ctx: ExecutionContext) => { 13 | const request = ctx.switchToHttp().getRequest(); 14 | if (!request.user) return null; 15 | return data ? request.user[data] : request.user; 16 | }); 17 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 开发环境配置 2 | NODE_ENV=xxx 3 | 4 | # Redis相关 5 | REDIS_HOST=xxx 6 | REDIS_PORT=xxx 7 | REDIS_DB=xxx 8 | 9 | # nodemailer相关 10 | NODEMAILER_SERVICE=xxx 11 | NODEMAILER_HOST=xxx 12 | NODEMAILER_PORT=xxx 13 | NODEMAILER_NAME=xxx 14 | NODEMAILER_AUTH_USER=xxx 15 | NODEMAILER_AUTH_PASS=xxx 16 | 17 | # mysql相关 18 | MYSQL_HOST=xxx 19 | MYSQL_PORT=xxx 20 | MYSQL_USER=xxx 21 | MYSQL_PASS=xxx 22 | MYSQL_DB=xxx 23 | 24 | # nest服务配置 25 | NEST_PORT=xxx 26 | 27 | # jwt配置 28 | JWT_SECRET=xxx 29 | JWT_ACCESS_TOKEN_EXPIRES_TIME=xxx 30 | JWT_REFRESH_TOKEN_EXPIRES_TIME=xxx 31 | 32 | # 其他测试环境配置 33 | CAPTCHA_SECRET=xxx 34 | 35 | # 跨域配置 36 | CORS_ORIGIN=xxx 37 | 38 | # Cloudflare R2配置 39 | R2_ACCESS_KEY_ID=xxx 40 | R2_SECRET_ACCESS_KEY=xxx 41 | R2_BUCKET=xxx 42 | R2_DOMAIN=xxx 43 | R2_ACCOUNT_ID=xxx -------------------------------------------------------------------------------- /src/modules/history/vo/history-item.vo.ts: -------------------------------------------------------------------------------- 1 | import type { History } from '../entities/history.entity'; 2 | import * as dayjs from 'dayjs'; 3 | 4 | export class HistoryItemVo { 5 | authorAvatar: string; 6 | authorId: string; 7 | authorName: string; 8 | id: string; 9 | imgList: string[]; 10 | cover: string; 11 | name: string; 12 | lastTime: string; 13 | 14 | constructor(history: History) { 15 | this.authorAvatar = history.user.avatar; 16 | this.authorId = history.user.id; 17 | this.authorName = history.user.username; 18 | this.id = history.illustration.id; 19 | this.imgList = history.illustration.imgList; 20 | this.cover = history.illustration.cover; 21 | this.name = history.illustration.name; 22 | this.lastTime = dayjs(history.lastTime).format('YYYY-MM-DD'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /scripts/password.ts: -------------------------------------------------------------------------------- 1 | import { hashPassword, verifyPassword } from '@/utils'; 2 | 3 | const [identifier, originalString] = process.argv.slice(2); 4 | 5 | if (!identifier || !originalString) { 6 | console.error('Usage: ts-node scripts/password.ts '); 7 | process.exit(1); 8 | } 9 | 10 | try { 11 | if (identifier === 'hash') { 12 | hashPassword(originalString).then((hashedPassword) => { 13 | console.log('Hashed password:', hashedPassword); 14 | }); 15 | } else if (identifier === 'verify') { 16 | const hashedPassword = process.argv[3]; 17 | verifyPassword(originalString, hashedPassword).then((isMatch) => 18 | console.log('Password matches:', isMatch), 19 | ); 20 | } 21 | } catch (error) { 22 | console.error('Error:', error); 23 | process.exit(1); 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/label/vo/label-detail.vo.ts: -------------------------------------------------------------------------------- 1 | import type { Label } from '@/modules/label/entities/label.entity'; 2 | 3 | export class LabelDetailVO { 4 | /** 5 | * 标签颜色,由后台进行随机不重复的颜色生成 6 | */ 7 | color: string; 8 | /** 9 | * 标签id 10 | */ 11 | id: string; 12 | /** 13 | * 标签封面图片,当该标签的作品数达到一定量级后,由管理员在后台进行上传,默认就是随机生成的纯色背景图 14 | */ 15 | cover: string | null; 16 | /** 17 | * 是否是我喜欢的标签 18 | */ 19 | isMyLike: boolean; 20 | /** 21 | * 标签名称 22 | */ 23 | name: string; 24 | /** 25 | * 该标签下的作品总数 26 | */ 27 | workCount: number; 28 | constructor(label: Label, isMyLike: boolean) { 29 | this.id = label.id; 30 | this.name = label.value; 31 | this.cover = label.cover; 32 | this.color = label.color; 33 | this.workCount = label.workCount; 34 | this.isMyLike = isMyLike; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/user/vo/user-item.vo.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '../entities/user.entity'; 2 | import { IllustrationItemVO } from '@/modules/illustration/vo/illustration-item.vo'; 3 | 4 | export class UserItemVo { 5 | id: string; 6 | username: string; 7 | email: string; 8 | avatar: string; 9 | intro: string; 10 | isFollowing: boolean; 11 | works?: IllustrationItemVO[]; 12 | 13 | constructor(user: User, isFollowing: boolean, workLikeList: boolean[]) { 14 | this.id = user.id; 15 | this.username = user.username; 16 | this.email = user.email; 17 | this.avatar = user.littleAvatar; 18 | this.intro = user.signature; 19 | this.isFollowing = isFollowing; 20 | if (user.illustrations && workLikeList.length) 21 | this.works = user.illustrations.map( 22 | (work, index) => new IllustrationItemVO(work, workLikeList[index]), 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/favorite/favorite.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { FavoriteService } from './favorite.service'; 3 | import { FavoriteController } from './favorite.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Favorite } from './entities/favorite.entity'; 6 | import { CollectRecord } from './entities/collect-record.entity'; 7 | import { Illustration } from '../illustration/entities/illustration.entity'; 8 | import { UserModule } from '../user/user.module'; 9 | import { User } from '../user/entities/user.entity'; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([Favorite, CollectRecord, Illustration, User]), 14 | forwardRef(() => UserModule), 15 | ], 16 | controllers: [FavoriteController], 17 | providers: [FavoriteService], 18 | exports: [FavoriteService], 19 | }) 20 | export class FavoriteModule {} 21 | -------------------------------------------------------------------------------- /src/modules/user/entities/follow.entity.ts: -------------------------------------------------------------------------------- 1 | // /src/user/entities/follow.entity.ts 2 | // 用户关注关系的中间表 3 | 4 | import { User } from '@/modules/user/entities/user.entity'; 5 | import { Entity, JoinColumn, ManyToOne, PrimaryColumn, CreateDateColumn } from 'typeorm'; 6 | 7 | @Entity({ 8 | name: 'users_following_users', 9 | }) 10 | export class Follow { 11 | @PrimaryColumn({ 12 | type: 'uuid', 13 | generated: 'uuid', 14 | comment: '采用uuid的形式', 15 | }) 16 | id: string; 17 | 18 | @ManyToOne(() => User, (user) => user.followers, { 19 | onDelete: 'CASCADE', 20 | }) 21 | @JoinColumn({ name: 'follower_id' }) 22 | follower: User; 23 | 24 | @ManyToOne(() => User, (user) => user.following, { 25 | onDelete: 'CASCADE', 26 | }) 27 | @JoinColumn({ name: 'following_id' }) 28 | following: User; 29 | 30 | @CreateDateColumn({ 31 | comment: '关注时间', 32 | }) 33 | followTime: Date; 34 | } 35 | -------------------------------------------------------------------------------- /src/modules/illustration/vo/illustration-item.vo.ts: -------------------------------------------------------------------------------- 1 | import type { Illustration } from '@/modules/illustration/entities/illustration.entity'; 2 | import * as dayjs from 'dayjs'; 3 | 4 | export class IllustrationItemVO { 5 | authorAvatar: string; 6 | authorId: string; 7 | authorName: string; 8 | id: string; 9 | imgList: string[]; 10 | cover: string; 11 | name: string; 12 | isLiked: boolean; 13 | createdAt: string; 14 | 15 | constructor(illustration: Illustration, isLiked: boolean) { 16 | this.authorAvatar = illustration.user.littleAvatar; 17 | this.authorId = illustration.user.id; 18 | this.authorName = illustration.user.username; 19 | this.id = illustration.id; 20 | this.imgList = illustration.imgList; 21 | this.cover = illustration.cover; 22 | this.name = illustration.name; 23 | this.isLiked = isLiked; 24 | this.createdAt = dayjs(illustration.createdTime).format('YYYY-MM-DD'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/user/dto/register-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, Length, Matches, MaxLength, MinLength } from 'class-validator'; 2 | 3 | export class RegisterUserDto { 4 | @IsEmail( 5 | { 6 | allow_ip_domain: false, 7 | allow_utf8_local_part: true, 8 | require_tld: true, 9 | }, 10 | { 11 | message: '邮箱格式不正确', 12 | }, 13 | ) 14 | @IsNotEmpty({ 15 | message: '邮箱不能为空', 16 | }) 17 | email: string; 18 | 19 | @Length(6, 6, { 20 | message: '验证码长度必须为6', 21 | }) 22 | @IsNotEmpty({ 23 | message: '验证码不能为空', 24 | }) 25 | @Matches(/^\d{6}$/, { 26 | message: '验证码必须为数字', 27 | }) 28 | verification_code: string; 29 | 30 | @MaxLength(31, { 31 | message: '密码长度不能大于31', 32 | }) 33 | @MinLength(6, { 34 | message: '密码长度不能小于6', 35 | }) 36 | @IsNotEmpty({ 37 | message: '密码不能为空', 38 | }) 39 | @Matches(/^(?=.*[A-Za-z])(?=.*\d)[\s\S]{6,}$/, { 40 | message: '密码必须包含字母和数字', 41 | }) 42 | password: string; 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/user/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { Matches, Length, IsOptional, IsEmail, IsIn } from 'class-validator'; 2 | 3 | export class UpdateUserDto { 4 | @IsOptional() 5 | @IsEmail( 6 | { 7 | allow_ip_domain: false, 8 | allow_utf8_local_part: true, 9 | require_tld: true, 10 | }, 11 | { 12 | message: '邮箱格式不正确', 13 | }, 14 | ) 15 | email?: string; 16 | 17 | @IsOptional() 18 | @Length(1, 31, { 19 | message: '用户名长度必须在1到31个字符之间', 20 | }) 21 | username?: string; 22 | 23 | @IsOptional() 24 | @Length(1, 255, { 25 | message: '签名长度必须在1到255个字符之间', 26 | }) 27 | signature?: string; 28 | 29 | @IsOptional() 30 | @Matches(/(https?:\/\/.*)/, { 31 | message: '头像地址格式不正确,需要以http或https开头', 32 | }) 33 | avatar?: string; 34 | 35 | @IsOptional() 36 | @Matches(/(https?:\/\/.*)/, { 37 | message: '背景图片地址格式不正确,需要以http或https开头', 38 | }) 39 | backgroundImg?: string; 40 | 41 | @IsOptional() 42 | @IsIn([0, 1, 2], { 43 | message: '性别只能是0, 1或2', 44 | }) 45 | gender?: 0 | 1 | 2; 46 | } 47 | -------------------------------------------------------------------------------- /src/modules/user/entities/like-works.entity.ts: -------------------------------------------------------------------------------- 1 | // /src/user/entities/like-works.entity.ts 2 | // 用户喜欢的作品中间表 3 | 4 | import { Illustration } from '@/modules/illustration/entities/illustration.entity'; 5 | import { User } from '@/modules/user/entities/user.entity'; 6 | import { Entity, JoinColumn, ManyToOne, PrimaryColumn, CreateDateColumn } from 'typeorm'; 7 | 8 | @Entity({ 9 | name: 'users_like_works_illustrations', 10 | }) 11 | export class LikeWorks { 12 | @PrimaryColumn({ 13 | type: 'uuid', 14 | generated: 'uuid', 15 | comment: '采用uuid的形式', 16 | }) 17 | id: string; 18 | 19 | @ManyToOne(() => User, (user) => user.likeWorks, { 20 | onDelete: 'CASCADE', 21 | }) 22 | @JoinColumn({ name: 'user_id' }) 23 | user: User; 24 | 25 | @ManyToOne(() => Illustration, (illustration) => illustration.likeUsers, { 26 | onDelete: 'CASCADE', 27 | }) 28 | @JoinColumn({ name: 'illustration_id' }) 29 | illustration: Illustration; 30 | 31 | @CreateDateColumn({ 32 | comment: '喜欢时间', 33 | }) 34 | likeTime: Date; 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/illustration/entities/work-push-temp.entity.ts: -------------------------------------------------------------------------------- 1 | // /src/illustration/entities/work-temp.entity.ts 2 | // 推送插画记录暂存实体 3 | 4 | import { Illustration } from '@/modules/illustration/entities/illustration.entity'; 5 | import { User } from '@/modules/user/entities/user.entity'; 6 | import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; 7 | 8 | @Entity({ 9 | name: 'work_push_temp', 10 | }) 11 | export class WorkPushTemp { 12 | @PrimaryColumn({ 13 | type: 'uuid', 14 | generated: 'uuid', 15 | comment: '采用uuid的形式', 16 | }) 17 | id: string; 18 | 19 | @ManyToOne(() => User, { 20 | onDelete: 'CASCADE', 21 | }) 22 | @JoinColumn({ name: 'author_id' }) 23 | author: User; 24 | 25 | @ManyToOne(() => User, (user) => user.recordWorks, { 26 | onDelete: 'CASCADE', 27 | }) 28 | @JoinColumn({ name: 'user_id' }) 29 | user: User; 30 | 31 | // 插画指的是插画家发布的新作 32 | @ManyToOne(() => Illustration, { 33 | onDelete: 'CASCADE', 34 | }) 35 | @JoinColumn({ name: 'illustration_id' }) 36 | illustration: Illustration; 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/history/entities/history.entity.ts: -------------------------------------------------------------------------------- 1 | // /src/history/entities/history.entity.ts 2 | // 历史记录实体 3 | 4 | import { Illustration } from '@/modules/illustration/entities/illustration.entity'; 5 | import { User } from '@/modules/user/entities/user.entity'; 6 | import { Entity, JoinColumn, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; 7 | 8 | @Entity({ 9 | name: 'history', 10 | }) 11 | export class History { 12 | @PrimaryColumn({ 13 | type: 'uuid', 14 | generated: 'uuid', 15 | comment: '历史记录id,采用uuid的形式', 16 | }) 17 | id: string; 18 | 19 | @UpdateDateColumn({ 20 | type: 'timestamp', 21 | comment: '最后访问时间', 22 | name: 'last_time', 23 | }) 24 | lastTime: Date; 25 | 26 | @ManyToOne(() => User, (user) => user.histories, { 27 | onDelete: 'CASCADE', 28 | }) 29 | @JoinColumn({ name: 'user_id' }) 30 | user: User; 31 | 32 | @ManyToOne(() => Illustration, (illustration) => illustration.histories, { 33 | onDelete: 'CASCADE', 34 | }) 35 | @JoinColumn({ name: 'illustration_id' }) 36 | illustration: Illustration; 37 | } 38 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import tseslint from '@typescript-eslint/eslint-plugin'; 3 | import tsparser from '@typescript-eslint/parser'; 4 | import prettier from 'eslint-plugin-prettier'; 5 | 6 | export default [ 7 | js.configs.recommended, 8 | { 9 | files: ['**/*.ts', '**/*.tsx'], 10 | languageOptions: { 11 | parser: tsparser, 12 | parserOptions: { 13 | project: 'tsconfig.json', 14 | tsconfigRootDir: import.meta.dirname, 15 | sourceType: 'module', 16 | }, 17 | }, 18 | plugins: { 19 | '@typescript-eslint': tseslint, 20 | prettier, 21 | }, 22 | rules: { 23 | '@typescript-eslint/interface-name-prefix': 'off', 24 | '@typescript-eslint/explicit-function-return-type': 'off', 25 | '@typescript-eslint/explicit-module-boundary-types': 'off', 26 | '@typescript-eslint/no-explicit-any': 'off', 27 | '@typescript-eslint/consistent-type-imports': [ 28 | 'error', 29 | { 30 | prefer: 'type-imports', 31 | disallowTypeAnnotations: false, 32 | }, 33 | ], 34 | 'prettier/prettier': 'error', 35 | }, 36 | }, 37 | ]; 38 | -------------------------------------------------------------------------------- /src/infra/email/email.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { createTransport } from 'nodemailer'; 4 | import type { Transporter } from 'nodemailer'; 5 | 6 | @Injectable() 7 | export class EmailService { 8 | transporter: Transporter; 9 | 10 | constructor(private readonly configService: ConfigService) { 11 | this.transporter = createTransport({ 12 | service: this.configService.get('NODEMAILER_SERVICE'), 13 | host: this.configService.get('NODEMAILER_HOST'), 14 | port: this.configService.get('NODEMAILER_PORT'), 15 | secure: true, 16 | auth: { 17 | user: this.configService.get('NODEMAILER_AUTH_USER'), 18 | pass: this.configService.get('NODEMAILER_AUTH_PASS'), 19 | }, 20 | }); 21 | } 22 | 23 | async sendEmail({ to, subject, html }) { 24 | return await this.transporter.sendMail({ 25 | from: { 26 | name: this.configService.get('NODEMAILER_NAME'), 27 | address: this.configService.get('NODEMAILER_AUTH_USER'), 28 | }, 29 | to, 30 | subject, 31 | html, 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/modules/favorite/entities/collect-record.entity.ts: -------------------------------------------------------------------------------- 1 | // /src/favorite/entities/collect-record.entity.ts 2 | // 收藏记录实体 3 | 4 | import { Illustration } from '@/modules/illustration/entities/illustration.entity'; 5 | import { User } from '@/modules/user/entities/user.entity'; 6 | import { CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; 7 | import { Favorite } from './favorite.entity'; 8 | 9 | @Entity({ 10 | name: 'collect_records', 11 | }) 12 | export class CollectRecord { 13 | @PrimaryColumn({ 14 | type: 'uuid', 15 | generated: 'uuid', 16 | comment: '主键id,采用uuid的形式', 17 | }) 18 | id: string; 19 | 20 | @ManyToOne(() => User, { 21 | onDelete: 'CASCADE', 22 | }) 23 | @JoinColumn({ name: 'user_id' }) 24 | user: User; 25 | 26 | @ManyToOne(() => Illustration, { 27 | onDelete: 'CASCADE', 28 | }) 29 | @JoinColumn({ name: 'illustration_id' }) 30 | illustration: Illustration; 31 | 32 | @ManyToOne(() => Favorite, { 33 | onDelete: 'CASCADE', 34 | }) 35 | @JoinColumn({ name: 'favorite_id' }) 36 | favorite: Favorite; 37 | 38 | @CreateDateColumn({ 39 | type: 'timestamp', 40 | comment: '收藏时间', 41 | name: 'created_at', 42 | }) 43 | createdAt: Date; 44 | } 45 | -------------------------------------------------------------------------------- /src/common/interceptors/multiple-imgs.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { FilesInterceptor } from '@nestjs/platform-express'; 2 | import * as multer from 'multer'; 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | import { hanaError } from '@/common/error/hanaError'; 6 | 7 | const MultipleImgsInterceptor = FilesInterceptor('images', 50, { 8 | storage: multer.diskStorage({ 9 | destination: (_, __, cb) => { 10 | const uploadPath = path.join(process.cwd(), 'uploads'); 11 | fs.mkdirSync(uploadPath, { recursive: true }); 12 | cb(null, uploadPath); 13 | }, 14 | filename: (_, file, cb) => { 15 | const uniqueSuffix = 16 | Date.now() + '-' + Math.round(Math.random() * 1e9) + '-' + file.originalname; 17 | cb(null, file.fieldname + '-' + uniqueSuffix); 18 | }, 19 | }), 20 | fileFilter: (_, file: Express.Multer.File, cb: multer.FileFilterCallback) => { 21 | const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg', 'image/gif']; 22 | if (allowedTypes.includes(file.mimetype)) { 23 | cb(null, true); 24 | } else { 25 | cb(new hanaError(11002)); 26 | } 27 | }, 28 | limits: { 29 | fileSize: 1024 * 1024 * 10, // 单个文件的最大尺寸 30 | }, 31 | }); 32 | 33 | export { MultipleImgsInterceptor }; 34 | -------------------------------------------------------------------------------- /src/modules/illustration/illustration.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { IllustrationService } from './illustration.service'; 3 | import { IllustrationController } from './illustration.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Illustration } from './entities/illustration.entity'; 6 | import { Illustrator } from '../illustrator/entities/illustrator.entity'; 7 | import { User } from '../user/entities/user.entity'; 8 | import { WorkPushTemp } from './entities/work-push-temp.entity'; 9 | import { Image } from './entities/image.entity'; 10 | import { UserModule } from '../user/user.module'; 11 | import { LabelModule } from '../label/label.module'; 12 | import { IllustratorModule } from '../illustrator/illustrator.module'; 13 | import { Favorite } from '../favorite/entities/favorite.entity'; 14 | 15 | @Module({ 16 | imports: [ 17 | TypeOrmModule.forFeature([Illustration, WorkPushTemp, Illustrator, User, Favorite, Image]), 18 | UserModule, 19 | LabelModule, 20 | IllustratorModule, 21 | ], 22 | controllers: [IllustrationController], 23 | providers: [IllustrationService], 24 | exports: [IllustrationService], 25 | }) 26 | export class IllustrationModule {} 27 | -------------------------------------------------------------------------------- /src/common/interceptors/single-img.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { FileInterceptor } from '@nestjs/platform-express'; 2 | import * as multer from 'multer'; 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | import { hanaError } from '@/common/error/hanaError'; 6 | import { suffixGenerator } from 'src/utils'; 7 | 8 | const SingleImgInterceptor = FileInterceptor('image', { 9 | storage: multer.diskStorage({ 10 | destination: (_, __, cb) => { 11 | try { 12 | fs.mkdirSync(path.join(process.cwd(), 'uploads')); 13 | } catch (e) { 14 | if (e.code !== 'EEXIST') { 15 | throw new hanaError(11001, e.message); 16 | } 17 | } 18 | cb(null, path.join(process.cwd(), 'uploads')); 19 | }, 20 | filename: (_, file, cb) => { 21 | cb(null, file.fieldname + '-' + suffixGenerator(file.originalname)); 22 | }, 23 | }), 24 | fileFilter: (_, file: Express.Multer.File, cb: multer.FileFilterCallback) => { 25 | const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg', 'image/gif']; 26 | if (allowedTypes.includes(file.mimetype)) { 27 | cb(null, true); 28 | } else { 29 | cb(new hanaError(11002)); 30 | } 31 | }, 32 | limits: { 33 | fileSize: 1024 * 1024 * 10, 34 | }, 35 | }); 36 | 37 | export { SingleImgInterceptor }; 38 | -------------------------------------------------------------------------------- /src/modules/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { UserService } from './user.service'; 3 | import { UserController } from './user.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { User } from './entities/user.entity'; 6 | import { Favorite } from '../favorite/entities/favorite.entity'; 7 | import { History } from '../history/entities/history.entity'; 8 | import { Illustration } from '../illustration/entities/illustration.entity'; 9 | import { LabelModule } from '../label/label.module'; 10 | import { FavoriteModule } from '../favorite/favorite.module'; 11 | import { WorkPushTemp } from '../illustration/entities/work-push-temp.entity'; 12 | import { LikeWorks } from './entities/like-works.entity'; 13 | import { Follow } from './entities/follow.entity'; 14 | 15 | @Module({ 16 | imports: [ 17 | TypeOrmModule.forFeature([ 18 | User, 19 | History, 20 | Illustration, 21 | Favorite, 22 | WorkPushTemp, 23 | LikeWorks, 24 | Follow, 25 | ]), 26 | forwardRef(() => LabelModule), 27 | forwardRef(() => FavoriteModule), 28 | ], 29 | controllers: [UserController], 30 | providers: [UserService], 31 | exports: [UserService], 32 | }) 33 | export class UserModule {} 34 | -------------------------------------------------------------------------------- /src/infra/redis/redis.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Inject, Module, OnModuleDestroy, Provider } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import Redis, { Redis as RedisClient } from 'ioredis'; 4 | 5 | export const REDIS_CLIENT = 'REDIS_CLIENT'; 6 | 7 | const redisProvider: Provider = { 8 | provide: REDIS_CLIENT, 9 | useFactory: async (configService: ConfigService) => { 10 | const client = new Redis({ 11 | host: configService.get('REDIS_HOST'), 12 | port: configService.get('REDIS_PORT'), 13 | db: configService.get('REDIS_DB'), 14 | password: configService.get('REDIS_PASS'), 15 | }); 16 | 17 | client.on('error', (err) => { 18 | console.error('Redis Client Error', err); 19 | }); 20 | 21 | console.log('Redis client connected successfully.'); 22 | return client; 23 | }, 24 | inject: [ConfigService], 25 | }; 26 | 27 | @Global() 28 | @Module({ 29 | providers: [redisProvider], 30 | exports: [redisProvider], 31 | }) 32 | export class RedisModule implements OnModuleDestroy { 33 | @Inject(REDIS_CLIENT) 34 | private readonly redisClient: RedisClient; 35 | 36 | onModuleDestroy() { 37 | this.redisClient.quit(); 38 | console.log('Redis client disconnected.'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/label/entities/label.entity.ts: -------------------------------------------------------------------------------- 1 | // /src/label/entities/label.entity.ts 2 | // 标签实体 3 | 4 | import { Illustration } from '@/modules/illustration/entities/illustration.entity'; 5 | import { User } from '@/modules/user/entities/user.entity'; 6 | import { Column, Entity, ManyToMany, PrimaryColumn } from 'typeorm'; 7 | 8 | @Entity({ 9 | name: 'labels', 10 | }) 11 | export class Label { 12 | @PrimaryColumn({ 13 | type: 'uuid', 14 | generated: 'uuid', 15 | comment: '标签id,采用uuid的形式', 16 | }) 17 | id: string; 18 | 19 | @Column({ 20 | type: 'varchar', 21 | length: 31, 22 | comment: '标签名', 23 | }) 24 | value: string; 25 | 26 | @Column({ 27 | type: 'varchar', 28 | length: 7, 29 | comment: '标签颜色,采用hex的格式', 30 | }) 31 | color: string; 32 | 33 | @Column({ 34 | type: 'varchar', 35 | length: 255, 36 | comment: '标签背景图', 37 | nullable: true, 38 | }) 39 | cover: string | null; 40 | 41 | @Column({ 42 | name: 'work_count', 43 | type: 'int', 44 | comment: '标签下的作品总数', 45 | default: 0, 46 | }) 47 | workCount: number; 48 | 49 | @ManyToMany(() => User, (user) => user.likedLabels, { 50 | onDelete: 'CASCADE', 51 | }) 52 | users: User[]; 53 | 54 | @ManyToMany(() => Illustration, (illustration) => illustration.labels, { 55 | onDelete: 'CASCADE', 56 | }) 57 | illustrations: Illustration[]; 58 | } 59 | -------------------------------------------------------------------------------- /src/modules/illustration/dto/upload-illustration.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsNotEmpty, 3 | IsOptional, 4 | ArrayMinSize, 5 | ArrayMaxSize, 6 | Matches, 7 | MaxLength, 8 | } from 'class-validator'; 9 | 10 | export class UploadIllustrationDto { 11 | @IsOptional() 12 | @MaxLength(63, { 13 | message: '作品名称长度不能大于63', 14 | }) 15 | name?: string; 16 | 17 | @IsOptional() 18 | @MaxLength(2047, { 19 | message: '作品简介长度不能大于2047', 20 | }) 21 | intro?: string; 22 | 23 | @IsNotEmpty({ 24 | message: '作品标签不能为空', 25 | }) 26 | @ArrayMinSize(1, { 27 | message: '至少需要一个标签', 28 | }) 29 | @ArrayMaxSize(50, { 30 | message: '标签数量不能超过50个', 31 | }) 32 | labels: string[]; 33 | 34 | @IsNotEmpty({ 35 | message: '是否转载不能为空', 36 | }) 37 | reprintType: number; 38 | 39 | @IsNotEmpty({ 40 | message: '是否开启评论不能为空', 41 | }) 42 | openComment: boolean; 43 | 44 | @IsNotEmpty({ 45 | message: '是否AI生成不能为空', 46 | }) 47 | isAIGenerated: boolean; 48 | 49 | @IsNotEmpty({ 50 | message: '作品列表不能为空', 51 | }) 52 | @ArrayMinSize(1, { 53 | message: '至少需要一个图片', 54 | }) 55 | @ArrayMaxSize(100, { 56 | message: '图片数量不能超过100张', 57 | }) 58 | imgList: string[]; 59 | 60 | @IsOptional() 61 | @Matches(/(https?:\/\/.*)/, { 62 | message: '请输入一个有效的URL地址!', 63 | }) 64 | workUrl?: string; 65 | 66 | @IsOptional() 67 | illustratorInfo?: { 68 | name: string; 69 | homeUrl: string; 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/modules/comment/comment.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, Body, Inject, Query } from '@nestjs/common'; 2 | import { CommentService } from './comment.service'; 3 | import { CreateCommentDto } from './dto/create-comment.dto'; 4 | import { RequireLogin, UserInfo } from '@/common/decorators/login.decorator'; 5 | import { JwtUserData } from '@/common/guards/auth.guard'; 6 | import { CommentItemVO } from './vo/comment-item.vo'; 7 | 8 | @Controller('comment') 9 | export class CommentController { 10 | @Inject(CommentService) 11 | private readonly commentService: CommentService; 12 | 13 | @Get('list') // 获取某个作品的评论列表 14 | async getCommentList(@Query('id') id: string) { 15 | const comments = await this.commentService.getCommentList(id); 16 | return comments.map((comment) => new CommentItemVO(comment)); 17 | } 18 | 19 | @Post('new') // 新增评论 20 | @RequireLogin() 21 | async createComment( 22 | @UserInfo() userInfo: JwtUserData, 23 | @Body() createCommentDto: CreateCommentDto, 24 | ) { 25 | const { id } = userInfo; 26 | await this.commentService.createComment(id, createCommentDto); 27 | return '评论成功!'; 28 | } 29 | 30 | @Post('delete') // 删除评论 31 | @RequireLogin() 32 | async deleteComment(@UserInfo() userInfo: JwtUserData, @Body('id') commentId: string) { 33 | const { id } = userInfo; 34 | await this.commentService.deleteComment(id, commentId); 35 | return '删除成功!'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/services/scripts/scripts.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ScriptsService } from './scripts.service'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { User } from '@/modules/user/entities/user.entity'; 5 | import { Illustrator } from '@/modules/illustrator/entities/illustrator.entity'; 6 | import { Illustration } from '@/modules/illustration/entities/illustration.entity'; 7 | import { Image } from '@/modules/illustration/entities/image.entity'; 8 | import { UserModule } from '@/modules/user/user.module'; 9 | import { LabelModule } from '@/modules/label/label.module'; 10 | import { IllustratorModule } from '@/modules/illustrator/illustrator.module'; 11 | import { IllustrationModule } from '@/modules/illustration/illustration.module'; 12 | import { R2Module } from '@/infra/r2/r2.module'; 13 | import { ConfigModule } from '@/infra/config/config.module'; 14 | import { DatabaseModule } from '@/infra/database/database.module'; 15 | import { RedisModule } from '@/infra/redis/redis.module'; 16 | import { JwtModule } from '@/infra/jwt/jwt.module'; 17 | 18 | @Module({ 19 | imports: [ 20 | ConfigModule, 21 | DatabaseModule, 22 | RedisModule, 23 | JwtModule, 24 | R2Module, 25 | UserModule, 26 | LabelModule, 27 | IllustratorModule, 28 | IllustrationModule, 29 | TypeOrmModule.forFeature([Illustration, Illustrator, User, Image]), 30 | ], 31 | providers: [ScriptsService], 32 | }) 33 | export class ScriptsModule {} 34 | -------------------------------------------------------------------------------- /src/common/interceptors/invoke-record.interceptor.ts: -------------------------------------------------------------------------------- 1 | // src/interceptors/invoke-record.interceptor.ts 2 | // 定义用于记录请求信息的拦截器 3 | 4 | import { 5 | type CallHandler, 6 | type ExecutionContext, 7 | type NestInterceptor, 8 | Injectable, 9 | Logger, 10 | } from '@nestjs/common'; 11 | import { tap, type Observable } from 'rxjs'; 12 | import type { Response } from 'express'; 13 | import type { AuthenticatedRequest } from '@/types'; 14 | 15 | @Injectable() 16 | export class InvokeRecordInterceptor implements NestInterceptor { 17 | // Logger 是 NestJS 提供的日志工具,提供了一系列的方法用于记录日志 18 | private readonly logger = new Logger(InvokeRecordInterceptor.name); 19 | 20 | intercept( 21 | context: ExecutionContext, 22 | next: CallHandler, 23 | ): Observable | Promise> { 24 | const request = context.switchToHttp().getRequest(); 25 | const response = context.switchToHttp().getResponse(); 26 | 27 | const userAgent = request.headers['user-agent']; 28 | 29 | const { ip, method, path } = request; 30 | 31 | this.logger.debug( 32 | `${method} ${path} ${ip} ${userAgent}: ${context.getClass().name} ${ 33 | context.getHandler().name 34 | } invoked...`, 35 | ); 36 | 37 | this.logger.debug(`user: ${request.user?.id}, ${request.user?.username}`); 38 | 39 | const now = Date.now(); 40 | 41 | return next.handle().pipe( 42 | tap((res) => { 43 | this.logger.debug( 44 | `${method} ${path} ${ip} ${userAgent}: ${response.statusCode}: ${Date.now() - now}ms`, 45 | ); 46 | this.logger.debug(`Response: ${JSON.stringify(res)}`); 47 | }), 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/modules/user/vo/login.vo.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '../entities/user.entity'; 2 | import type { LabelItemVO } from '@/modules/label/vo/label-item.vo'; 3 | 4 | export class userLoginInfoVo { 5 | id: string; 6 | username: string; 7 | email: string; 8 | backgroundImg: string; 9 | avatar: string; 10 | littleAvatar: string; 11 | signature: string; 12 | gender: number; 13 | fanCount: number; 14 | followCount: number; 15 | originCount: number; 16 | reprintedCount: number; 17 | likeCount: number; 18 | collectCount: number; 19 | favoriteCount: number; 20 | createdTime: Date; 21 | updatedTime: Date; 22 | likedLabels: LabelItemVO[]; 23 | 24 | constructor(user: User) { 25 | this.id = user.id; 26 | this.username = user.username; 27 | this.email = user.email; 28 | this.backgroundImg = user.backgroundImg; 29 | this.avatar = user.avatar; 30 | this.littleAvatar = user.littleAvatar; 31 | this.signature = user.signature; 32 | this.gender = user.gender; 33 | this.fanCount = user.fanCount; 34 | this.followCount = user.followCount; 35 | this.originCount = user.originCount; 36 | this.reprintedCount = user.reprintedCount; 37 | this.likeCount = user.likeCount; 38 | this.collectCount = user.collectCount; 39 | this.favoriteCount = user.favoriteCount; 40 | this.createdTime = user.createdTime; 41 | this.updatedTime = user.updatedTime; 42 | this.likedLabels = user.likedLabels.map((label) => ({ 43 | id: label.id, 44 | name: label.value, 45 | cover: label.cover, 46 | color: label.color, 47 | })); 48 | } 49 | } 50 | 51 | export class LoginUserVo { 52 | userInfo: userLoginInfoVo; 53 | accessToken: string; 54 | refreshToken: string; 55 | } 56 | -------------------------------------------------------------------------------- /src/modules/illustration/entities/image.entity.ts: -------------------------------------------------------------------------------- 1 | // /src/illustration/entities/image.entity.ts 2 | // 图片信息实体 3 | 4 | import { Illustration } from '@/modules/illustration/entities/illustration.entity'; 5 | import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; 6 | 7 | @Entity({ 8 | name: 'images', 9 | }) 10 | export class Image { 11 | @PrimaryColumn({ 12 | type: 'uuid', 13 | generated: 'uuid', 14 | comment: '采用uuid的形式', 15 | }) 16 | id: string; 17 | 18 | @Column({ 19 | type: 'varchar', 20 | length: 255, 21 | comment: '原图地址', 22 | }) 23 | originUrl: string; 24 | 25 | @Column({ 26 | type: 'int', 27 | width: 11, 28 | comment: '原图宽度', 29 | }) 30 | originWidth: number; 31 | 32 | @Column({ 33 | type: 'int', 34 | width: 11, 35 | comment: '原图高度', 36 | }) 37 | originHeight: number; 38 | 39 | @Column({ 40 | type: 'int', 41 | width: 11, 42 | comment: '原图大小,以KB为单位', 43 | default: 0, 44 | }) 45 | originSize: number; 46 | 47 | @Column({ 48 | type: 'varchar', 49 | length: 255, 50 | comment: '缩略图地址', 51 | }) 52 | thumbnailUrl: string; 53 | 54 | @Column({ 55 | type: 'int', 56 | width: 11, 57 | comment: '缩略图宽度', 58 | }) 59 | thumbnailWidth: number; 60 | 61 | @Column({ 62 | type: 'int', 63 | width: 11, 64 | comment: '缩略图高度', 65 | }) 66 | thumbnailHeight: number; 67 | 68 | @Column({ 69 | type: 'int', 70 | width: 11, 71 | comment: '缩略图大小,以KB为单位', 72 | default: 0, 73 | }) 74 | thumbnailSize: number; 75 | 76 | @ManyToOne(() => Illustration, (Illustration) => Illustration.images, { 77 | onDelete: 'CASCADE', 78 | }) 79 | @JoinColumn({ name: 'illustration_id' }) 80 | illustration: Illustration; 81 | } 82 | -------------------------------------------------------------------------------- /src/modules/comment/vo/comment-item.vo.ts: -------------------------------------------------------------------------------- 1 | import type { Comment } from '../entities/comment.entity'; 2 | import * as dayjs from 'dayjs'; 3 | 4 | export interface CommentAuthorInfo { 5 | id: string; 6 | avatar: string; 7 | username: string; 8 | } 9 | 10 | export interface CommentItem { 11 | id: string; 12 | authorInfo: CommentAuthorInfo; 13 | content: string; 14 | createdAt: string; 15 | level: number; 16 | replyTo?: ReplyInfo; 17 | } 18 | 19 | export interface ReplyInfo { 20 | id: string; 21 | username: string; 22 | } 23 | 24 | export class CommentItemVO { 25 | id: string; 26 | authorInfo: CommentAuthorInfo; 27 | childComments: CommentItem[]; 28 | content: string; 29 | createdAt: string; 30 | level: number; 31 | replyTo?: ReplyInfo; 32 | 33 | constructor(comment: Comment) { 34 | this.id = comment.id; 35 | this.authorInfo = { 36 | id: comment.user.id, 37 | avatar: comment.user.avatar, 38 | username: comment.user.username, 39 | }; 40 | this.content = comment.content; 41 | this.createdAt = dayjs(comment.createTime).format('YYYY-MM-DD HH:mm:ss'); 42 | this.level = comment.level; 43 | this.childComments = comment.replies.map((reply) => { 44 | const result: CommentItem = { 45 | id: reply.id, 46 | authorInfo: { 47 | id: reply.user.id, 48 | avatar: reply.user.avatar, 49 | username: reply.user.username, 50 | }, 51 | content: reply.content, 52 | createdAt: dayjs(reply.createTime).format('YYYY-MM-DD HH:mm:ss'), 53 | level: reply.level, 54 | }; 55 | if (reply.replyToUser) { 56 | result.replyTo = { 57 | id: reply.replyToUser.id, 58 | username: reply.replyToUser.username, 59 | }; 60 | } 61 | return result; 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/modules/comment/entities/comment.entity.ts: -------------------------------------------------------------------------------- 1 | import { Illustration } from '@/modules/illustration/entities/illustration.entity'; 2 | import { User } from '@/modules/user/entities/user.entity'; 3 | import { 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | JoinColumn, 8 | ManyToOne, 9 | OneToMany, 10 | PrimaryColumn, 11 | } from 'typeorm'; 12 | 13 | @Entity({ 14 | name: 'comments', 15 | }) 16 | export class Comment { 17 | @PrimaryColumn({ 18 | type: 'uuid', 19 | generated: 'uuid', 20 | comment: '评论id,采用uuid的形式', 21 | }) 22 | id: string; 23 | 24 | @Column({ 25 | type: 'varchar', 26 | length: 2047, 27 | comment: '评论内容', 28 | }) 29 | content: string; 30 | 31 | @Column({ 32 | type: 'int', 33 | comment: '评论等级,0为一级评论,1为二级评论', 34 | }) 35 | level: number; 36 | 37 | @CreateDateColumn({ 38 | type: 'timestamp', 39 | comment: '评论创建时间', 40 | }) 41 | createTime: Date; 42 | 43 | @ManyToOne(() => Comment, (comment) => comment.replies, { 44 | onDelete: 'CASCADE', 45 | }) 46 | @JoinColumn({ name: 'res_to_comment_id' }) 47 | replyTo: Comment; // 这条评论回复的评论 48 | 49 | @ManyToOne(() => User, { 50 | nullable: true, 51 | onDelete: 'SET NULL', 52 | }) 53 | @JoinColumn({ name: 'res_to_user_id' }) 54 | replyToUser: User; // 这条评论回复的用户(仅当二级评论回复他人时有值,便于区分) 55 | 56 | @OneToMany(() => Comment, (comment) => comment.replyTo) 57 | replies: Comment[]; // 回复这条评论的评论 58 | 59 | @ManyToOne(() => User, (user) => user.comments, { 60 | nullable: true, 61 | onDelete: 'SET NULL', 62 | }) 63 | @JoinColumn({ name: 'user_id' }) 64 | user: User; // 评论作者 65 | 66 | @ManyToOne(() => Illustration, (illustration) => illustration.comments, { 67 | onDelete: 'CASCADE', 68 | }) 69 | @JoinColumn({ name: 'illustration_id' }) 70 | illustration: Illustration; // 评论所属作品 71 | } 72 | -------------------------------------------------------------------------------- /src/common/error/error.filter.ts: -------------------------------------------------------------------------------- 1 | // src/error/error.filter.ts 2 | // 处理全局异常的过滤器 3 | 4 | import { Catch, HttpStatus, HttpException } from '@nestjs/common'; 5 | import { QueryFailedError } from 'typeorm'; 6 | import type { Response } from 'express'; 7 | import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common'; 8 | import { MulterError } from 'multer'; 9 | 10 | @Catch(Error) 11 | export class ErrorFilter implements ExceptionFilter { 12 | catch(exception: Error, host: ArgumentsHost): any { 13 | const response = host.switchToHttp().getResponse(); 14 | 15 | if (exception instanceof HttpException) { 16 | const statusCode = exception.getStatus(); 17 | const data = exception.getResponse(); 18 | const { message } = exception; 19 | const errorInfo = { 20 | code: data?.['code'] || statusCode, 21 | message: data?.['message'] || message || 'Unknown Error', 22 | }; 23 | if (statusCode === HttpStatus.PAYLOAD_TOO_LARGE) { 24 | errorInfo['message'] = '上传文件过大,跟根据上传的具体要求说明重新上传'; 25 | } 26 | response.status(statusCode).json(errorInfo); 27 | } else if (exception instanceof QueryFailedError) { 28 | response.status(HttpStatus.BAD_REQUEST).json({ 29 | code: HttpStatus.BAD_REQUEST, 30 | message: exception.message || 'Database Error', 31 | }); 32 | } else if (exception instanceof MulterError) { 33 | response.status(HttpStatus.BAD_REQUEST).json({ 34 | code: HttpStatus.BAD_REQUEST, 35 | message: exception.message || 'File Upload Error', 36 | }); 37 | } else { 38 | response 39 | .status( 40 | exception?.['statusCode'] || exception?.['status'] || HttpStatus.INTERNAL_SERVER_ERROR, 41 | ) 42 | .json({ 43 | code: HttpStatus.INTERNAL_SERVER_ERROR, 44 | message: exception.message || 'Internal Server Error', 45 | }); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/infra/r2/r2.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; 4 | import * as fs from 'node:fs'; 5 | 6 | @Injectable() 7 | export class R2Service { 8 | private S3: S3Client; 9 | private bucket: string; 10 | 11 | constructor(private readonly configService: ConfigService) { 12 | this.S3 = new S3Client({ 13 | region: 'apac', 14 | endpoint: `https://${this.configService.get('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`, 15 | credentials: { 16 | accessKeyId: this.configService.get('R2_ACCESS_KEY_ID'), 17 | secretAccessKey: this.configService.get('R2_SECRET_ACCESS_KEY'), 18 | }, 19 | }); 20 | this.bucket = this.configService.get('R2_BUCKET'); 21 | } 22 | 23 | // 上传单个文件至 Cloudflare R2 24 | uploadFileToR2 = (filePath: string, targetPath: string, deleteSource = true): Promise => { 25 | return new Promise((resolve, reject) => { 26 | const putCommand = new PutObjectCommand({ 27 | Bucket: this.bucket, 28 | Key: targetPath, 29 | Body: fs.createReadStream(filePath), 30 | }); 31 | this.S3.send(putCommand) 32 | .then(() => { 33 | resolve(`https://${this.configService.get('R2_DOMAIN')}/${targetPath}`); 34 | if (deleteSource) fs.unlinkSync(filePath); 35 | }) 36 | .catch((error) => { 37 | reject(error); 38 | }); 39 | }); 40 | }; 41 | 42 | // 删除 Cloudflare R2 中的文件 43 | deleteFileFromR2 = (targetPath: string): Promise => { 44 | return new Promise((resolve, reject) => { 45 | const deleteCommand = new DeleteObjectCommand({ 46 | Bucket: this.bucket, 47 | Key: targetPath, 48 | }); 49 | this.S3.send(deleteCommand) 50 | .then(() => { 51 | resolve(); 52 | }) 53 | .catch((error) => { 54 | reject(error); 55 | }); 56 | }); 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /src/common/guards/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject, UnauthorizedException } from '@nestjs/common'; 2 | import type { CanActivate, ExecutionContext } from '@nestjs/common'; 3 | import { JwtService } from '@nestjs/jwt'; 4 | import type { Observable } from 'rxjs'; 5 | import { Reflector } from '@nestjs/core'; 6 | import type { AuthenticatedRequest } from '@/types'; 7 | 8 | export interface JwtUserData { 9 | id: string; 10 | email: string; 11 | username: string; 12 | } 13 | 14 | @Injectable() 15 | export class AuthGuard implements CanActivate { 16 | @Inject() 17 | private readonly reflector: Reflector; 18 | 19 | @Inject(JwtService) 20 | private readonly jwtService: JwtService; 21 | 22 | canActivate(context: ExecutionContext): boolean | Promise | Observable { 23 | const request = context.switchToHttp().getRequest(); 24 | 25 | const authorization = request.headers.authorization || ''; 26 | 27 | const bearer = authorization.split(' '); 28 | 29 | const token = bearer[1]; 30 | 31 | const visitor = this.reflector.getAllAndOverride('visitor', [ 32 | context.getClass(), 33 | context.getHandler(), 34 | ]); 35 | 36 | if (visitor) { 37 | try { 38 | if (token) { 39 | const info = this.jwtService.verify(token); 40 | request.user = info; 41 | } 42 | return true; 43 | } catch { 44 | throw new UnauthorizedException('Token expired, please log in again'); 45 | } 46 | } 47 | 48 | const requireLogin = this.reflector.getAllAndOverride('require-login', [ 49 | context.getClass(), 50 | context.getHandler(), 51 | ]); 52 | 53 | if (!requireLogin) { 54 | return true; 55 | } 56 | 57 | try { 58 | const info = this.jwtService.verify(token); 59 | request.user = info; 60 | return true; 61 | } catch { 62 | throw new UnauthorizedException('Token expired, please log in again'); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/modules/user/vo/detail.vo.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '../entities/user.entity'; 2 | import * as dayjs from 'dayjs'; 3 | 4 | export class DetailUserVo { 5 | /** 6 | * 用户头像 7 | */ 8 | avatar: string; 9 | /** 10 | * 用户头像缩略图 11 | */ 12 | littleAvatar: string; 13 | /** 14 | * 用户背景图 15 | */ 16 | backgroundImg: string; 17 | /** 18 | * 用户收藏的作品数 19 | */ 20 | collectCount: number; 21 | /** 22 | * 用户账户的创建时间 23 | */ 24 | createdTime: string; 25 | /** 26 | * 用户邮箱 27 | */ 28 | email: string; 29 | /** 30 | * 用户粉丝数 31 | */ 32 | fanCount: number; 33 | /** 34 | * 用户的收藏夹数量 35 | */ 36 | favoriteCount: number; 37 | /** 38 | * 用户关注数 39 | */ 40 | followCount: number; 41 | /** 42 | * 用户性别,0-男,1-女,2-未知 43 | */ 44 | gender: number; 45 | /** 46 | * 用户id 47 | */ 48 | id: string; 49 | /** 50 | * 用户喜欢的作品数 51 | */ 52 | likeCount: number; 53 | /** 54 | * 用户发布的原创作品数 55 | */ 56 | originCount: number; 57 | /** 58 | * 用户发布的转载作品数 59 | */ 60 | reprintedCount: number; 61 | /** 62 | * 用户签名 63 | */ 64 | signature: string; 65 | /** 66 | * 用户名 67 | */ 68 | username: string; 69 | /** 70 | * 是否已关注 71 | */ 72 | isFollowed: boolean; 73 | 74 | constructor(user: User, isFollowed: boolean) { 75 | this.id = user.id; 76 | this.username = user.username; 77 | this.email = user.email; 78 | this.avatar = user.avatar; 79 | this.littleAvatar = user.littleAvatar; 80 | this.backgroundImg = user.backgroundImg; 81 | this.gender = user.gender; 82 | this.likeCount = user.likeCount; 83 | this.collectCount = user.collectCount; 84 | this.originCount = user.originCount; 85 | this.reprintedCount = user.reprintedCount; 86 | this.fanCount = user.fanCount; 87 | this.followCount = user.followCount; 88 | this.favoriteCount = user.favoriteCount; 89 | this.createdTime = dayjs(user.createdTime).format('YYYY-MM-DD HH:mm:ss'); 90 | this.signature = user.signature; 91 | this.isFollowed = isFollowed; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/infra/r2/r2.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Inject, 4 | Post, 5 | UploadedFile, 6 | UploadedFiles, 7 | UseInterceptors, 8 | } from '@nestjs/common'; 9 | import { SingleImgInterceptor } from '@/common/interceptors/single-img.interceptor'; 10 | import { MultipleImgsInterceptor } from '@/common/interceptors/multiple-imgs.interceptor'; 11 | import { R2Service } from '@/infra/r2/r2.service'; 12 | import { hanaError } from '@/common/error/hanaError'; 13 | 14 | @Controller('r2') 15 | export class R2Controller { 16 | @Inject() 17 | private readonly r2Service: R2Service; 18 | 19 | @Post('upload-single-img') 20 | @UseInterceptors(SingleImgInterceptor) 21 | async uploadImg(@UploadedFile() file: Express.Multer.File) { 22 | if (!file) throw new hanaError(11004); 23 | const filePath = file.path; 24 | const targetPath = 'images' + filePath.split('uploads')[1].replace(/\\/g, '/'); 25 | try { 26 | const result = await this.r2Service.uploadFileToR2(filePath, targetPath); 27 | return result; 28 | } catch (error) { 29 | throw new hanaError(11001, error.message); 30 | } 31 | } 32 | 33 | @Post('upload-multiple-imgs') 34 | @UseInterceptors(MultipleImgsInterceptor) 35 | async uploadImgs(@UploadedFiles() files: Express.Multer.File[]) { 36 | if (!files) throw new hanaError(11004); 37 | const results = []; 38 | for (const file of files) { 39 | const filePath = file.path; 40 | const targetPath = 'images' + filePath.split('uploads')[1].replace(/\\/g, '/'); 41 | try { 42 | const result = await this.r2Service.uploadFileToR2(filePath, targetPath); 43 | results.push(result); 44 | } catch (error) { 45 | throw new hanaError(11001, error.message); 46 | } 47 | } 48 | return results; 49 | } 50 | 51 | @Post('delete-single-img') 52 | async deleteImg(targetPath: string) { 53 | try { 54 | await this.r2Service.deleteFileFromR2(targetPath); 55 | } catch (error) { 56 | throw new hanaError(11002, error.message); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/modules/illustrator/entities/illustrator.entity.ts: -------------------------------------------------------------------------------- 1 | // /src/illustrator/entities/illustrator.entity.ts 2 | // 插画家实体 3 | 4 | import { Illustration } from '@/modules/illustration/entities/illustration.entity'; 5 | import { 6 | Column, 7 | CreateDateColumn, 8 | Entity, 9 | OneToMany, 10 | PrimaryColumn, 11 | UpdateDateColumn, 12 | } from 'typeorm'; 13 | 14 | @Entity({ 15 | name: 'illustrators', 16 | }) 17 | export class Illustrator { 18 | @PrimaryColumn({ 19 | type: 'uuid', 20 | generated: 'uuid', 21 | comment: '插画家id,采用uuid的形式', 22 | }) 23 | id: string; 24 | 25 | @Column({ 26 | type: 'varchar', 27 | length: 31, 28 | comment: '插画家名字', 29 | }) 30 | name: string; 31 | 32 | @Column({ 33 | type: 'varchar', 34 | length: 255, 35 | comment: '插画家头像地址URL', 36 | nullable: true, 37 | }) 38 | avatar: string; 39 | 40 | @Column({ 41 | type: 'varchar', 42 | length: 255, 43 | comment: '插画家头像缩略图地址URL', 44 | name: 'little_avatar', 45 | nullable: true, 46 | default: null, 47 | }) 48 | littleAvatar: string; 49 | 50 | @Column({ 51 | type: 'varchar', 52 | length: 255, 53 | comment: '插画家简介', 54 | default: '暂无简介', 55 | }) 56 | intro: string; 57 | 58 | @Column({ 59 | type: 'varchar', 60 | length: 255, 61 | comment: '插画家主页地址URL', 62 | name: 'home_url', 63 | }) 64 | homeUrl: string; 65 | 66 | @Column({ 67 | type: 'int', 68 | comment: '插画家作品数量', 69 | default: 0, 70 | name: 'work_count', 71 | }) 72 | workCount: number; 73 | 74 | @Column({ 75 | type: 'boolean', 76 | comment: '插画家状态,0-正常,1-删除', 77 | name: 'status', 78 | default: 0, 79 | }) 80 | status: number; 81 | 82 | @CreateDateColumn({ 83 | type: 'timestamp', 84 | comment: '插画家创建时间', 85 | name: 'created_time', 86 | }) 87 | createdTime: Date; 88 | 89 | @UpdateDateColumn({ 90 | type: 'timestamp', 91 | comment: '插画家更新时间', 92 | name: 'updated_time', 93 | }) 94 | updatedTime: Date; 95 | 96 | @OneToMany(() => Illustration, (illustration) => illustration.illustrator) 97 | illustrations: Illustration[]; 98 | } 99 | -------------------------------------------------------------------------------- /src/modules/favorite/entities/favorite.entity.ts: -------------------------------------------------------------------------------- 1 | // /src/favorite/entities/favorite.entity.ts 2 | // 收藏夹实体 3 | 4 | import { Illustration } from '@/modules/illustration/entities/illustration.entity'; 5 | import { User } from '@/modules/user/entities/user.entity'; 6 | import { 7 | Column, 8 | CreateDateColumn, 9 | Entity, 10 | JoinColumn, 11 | ManyToMany, 12 | ManyToOne, 13 | PrimaryColumn, 14 | UpdateDateColumn, 15 | } from 'typeorm'; 16 | 17 | @Entity({ 18 | name: 'favorites', 19 | }) 20 | export class Favorite { 21 | @PrimaryColumn({ 22 | type: 'uuid', 23 | generated: 'uuid', 24 | comment: '收藏夹id,采用uuid的形式', 25 | }) 26 | id: string; 27 | 28 | @Column({ 29 | type: 'varchar', 30 | length: 31, 31 | comment: '收藏夹名称', 32 | }) 33 | name: string; 34 | 35 | @Column({ 36 | type: 'varchar', 37 | length: 255, 38 | comment: '收藏夹简介', 39 | }) 40 | introduce: string; 41 | 42 | @Column({ 43 | type: 'varchar', 44 | length: 255, 45 | comment: '收藏夹封面图片URL地址', 46 | default: null, 47 | nullable: true, 48 | }) 49 | cover: string; 50 | 51 | @Column({ 52 | type: 'int', 53 | comment: '收藏夹顺序(从0开始)', 54 | }) 55 | order: number; 56 | 57 | @Column({ 58 | type: 'int', 59 | comment: '收藏夹内的作品数量', 60 | name: 'work_count', 61 | default: 0, 62 | }) 63 | workCount: number; 64 | 65 | @Column({ 66 | type: 'boolean', 67 | comment: '收藏夹状态,0-正常,1-删除', 68 | name: 'status', 69 | default: 0, 70 | }) 71 | status: number; 72 | 73 | @CreateDateColumn({ 74 | type: 'timestamp', 75 | comment: '收藏夹创建时间', 76 | name: 'created_at', 77 | }) 78 | createdAt: Date; 79 | 80 | @UpdateDateColumn({ 81 | type: 'timestamp', 82 | comment: '收藏夹更新时间', 83 | name: 'updated_at', 84 | }) 85 | updatedAt: Date; 86 | 87 | @ManyToOne(() => User, (user) => user.favorites, { 88 | onDelete: 'CASCADE', 89 | }) 90 | @JoinColumn({ name: 'user_id' }) 91 | user: User; 92 | 93 | @ManyToMany(() => Illustration, (illustration) => illustration.favorites, { 94 | onDelete: 'CASCADE', 95 | }) 96 | illustrations: Illustration[]; 97 | } 98 | -------------------------------------------------------------------------------- /src/infra/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { CollectRecord } from '@/modules/favorite/entities/collect-record.entity'; 2 | import { Favorite } from '@/modules/favorite/entities/favorite.entity'; 3 | import { Illustration } from '@/modules/illustration/entities/illustration.entity'; 4 | import { WorkPushTemp } from '@/modules/illustration/entities/work-push-temp.entity'; 5 | import { Illustrator } from '@/modules/illustrator/entities/illustrator.entity'; 6 | import { Label } from '@/modules/label/entities/label.entity'; 7 | import { Follow } from '@/modules/user/entities/follow.entity'; 8 | import { LikeWorks } from '@/modules/user/entities/like-works.entity'; 9 | import { User } from '@/modules/user/entities/user.entity'; 10 | import { Comment } from '@/modules/comment/entities/comment.entity'; 11 | import { History } from '@/modules/history/entities/history.entity'; 12 | import { Image } from '@/modules/illustration/entities/image.entity'; 13 | import { Module } from '@nestjs/common'; 14 | import { ConfigService } from '@nestjs/config'; 15 | import { TypeOrmModule } from '@nestjs/typeorm'; 16 | 17 | @Module({ 18 | imports: [ 19 | TypeOrmModule.forRootAsync({ 20 | useFactory(configService: ConfigService) { 21 | return { 22 | type: 'mysql', 23 | host: configService.get('MYSQL_HOST'), 24 | port: configService.get('MYSQL_PORT'), 25 | username: configService.get('MYSQL_USER'), 26 | password: configService.get('MYSQL_PASS'), 27 | database: configService.get('MYSQL_DB'), 28 | synchronize: true, 29 | logging: false, 30 | entities: [ 31 | User, 32 | Illustrator, 33 | Illustration, 34 | Label, 35 | Comment, 36 | History, 37 | Favorite, 38 | WorkPushTemp, 39 | CollectRecord, 40 | LikeWorks, 41 | Follow, 42 | Image, 43 | ], 44 | poolSize: 10, 45 | connectorPackage: 'mysql2', 46 | extra: { 47 | authPlugin: 'sha256_password', 48 | }, 49 | }; 50 | }, 51 | inject: [ConfigService], 52 | }), 53 | ], 54 | }) 55 | export class DatabaseModule {} 56 | -------------------------------------------------------------------------------- /src/modules/label/label.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Inject, Post, Query } from '@nestjs/common'; 2 | import { LabelService } from './label.service'; 3 | import type { NewLabelDto } from './dto/new-label.dto'; 4 | import { LabelItemVO } from './vo/label-item.vo'; 5 | import { AllowVisitor, RequireLogin, UserInfo } from '@/common/decorators/login.decorator'; 6 | import { JwtUserData } from '@/common/guards/auth.guard'; 7 | import { UserService } from '../user/user.service'; 8 | import { LabelDetailVO } from './vo/label-detail.vo'; 9 | 10 | @Controller('label') 11 | export class LabelController { 12 | @Inject(LabelService) 13 | private readonly labelService: LabelService; 14 | 15 | @Inject(UserService) 16 | private readonly userService: UserService; 17 | 18 | @Post('new') // 新增标签 19 | @RequireLogin() 20 | async newLabels(@Body() labels: NewLabelDto[]) { 21 | return await this.labelService.createItems(labels.map((label) => label.value)); 22 | } 23 | 24 | @Get('recommend') // 获取推荐标签 25 | async getRecommendLabels() { 26 | const source = await this.labelService.getRecommendLabels(); 27 | return source.map((label) => new LabelItemVO(label)); 28 | } 29 | 30 | @Get('search') // 搜索标签 31 | async searchLabels(@Query('keyword') keyword: string) { 32 | const source = await this.labelService.searchLabels(keyword); 33 | return source.map((label) => new LabelItemVO(label)); 34 | } 35 | 36 | @Get('list') // 分页获取标签列表 37 | async getLabelList(@Query('pageSize') pageSize: number, @Query('current') current: number) { 38 | const source = await this.labelService.getLabelsInPages(pageSize, current); 39 | return source.map((label) => new LabelItemVO(label)); 40 | } 41 | 42 | @Get('detail') // 获取标签详情 43 | @AllowVisitor() 44 | async getLabelDetail(@UserInfo() userInfo: JwtUserData, @Query('name') name: string) { 45 | const label = await this.labelService.findItemByValue(name); 46 | if (!label) return null; 47 | return new LabelDetailVO( 48 | label, 49 | userInfo ? await this.userService.isLikedLabel(userInfo.id, label.id) : false, 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/common/error/errorList.ts: -------------------------------------------------------------------------------- 1 | // src/error/errorList.ts 2 | // 用于定义错误码和错误信息的映射关系(主动抛出异常hanaError) 3 | 4 | export const errorMessages: Map = new Map([ 5 | // 通用错误 6 | [10001, 'Unknown Error'], 7 | [10002, 'Invalid options'], 8 | 9 | // 用户相关错误 10 | [10101, 'User not found'], 11 | [10102, 'User password error'], 12 | [10103, 'Email verification code error'], 13 | [10104, 'Invalid Email, Name, Password, or Verification Code Format'], 14 | [10105, 'User already exists'], 15 | [10106, 'Invalid token'], 16 | [10108, 'You have sent a verification code, please wait'], 17 | [10110, 'This user has already been followed'], 18 | [10111, 'This user can not been found as a followed user'], 19 | [10112, 'You can not follow yourself'], 20 | 21 | // 分页相关错误 22 | [10201, 'Page number must be greater than 0'], 23 | [10202, 'Page size must be greater than 0'], 24 | 25 | // 插画家相关错误 26 | [10301, 'Illustrator already exists'], 27 | 28 | // 标签相关错误 29 | [10401, 'This tag has already been liked'], 30 | [10402, 'This tag can not been found as a liked tag'], 31 | [10403, 'Tag is not found'], 32 | [10404, 'Invalid sort type'], 33 | 34 | // 插画相关错误 35 | [10501, 'Illustration not found'], 36 | [10502, "Can not edit other user's work"], 37 | [10503, 'This work has already been liked'], 38 | [10504, 'This work can not been found as a liked work'], 39 | [10505, 'This work has already been collected'], 40 | [10506, 'This work can not been found as a collected work'], 41 | [10507, 'Unable to find the corresponding directory based on the path'], 42 | 43 | // 收藏相关错误 44 | [10601, 'Favorite not found'], 45 | [10602, 'This work has already been added to the collection'], 46 | [10603, 'This work can not been found as a collected work'], 47 | 48 | // 评论相关错误 49 | [10701, 'Comment not found'], 50 | [10702, 'Can not delete other user’s comment'], 51 | 52 | // 历史记录相关错误 53 | [10801, 'History not found'], 54 | 55 | // 插画家相关错误 56 | [10901, 'Illustrator not found'], 57 | [10902, 'Illustrator already exists'], 58 | 59 | // 文件上传错误 60 | [11001, 'File upload failed'], 61 | [11002, 'File type is not supported'], 62 | [11003, 'File size exceeds the limit'], 63 | [11004, 'File not detected'], 64 | ]); 65 | -------------------------------------------------------------------------------- /src/services/img-handler/img-handler.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import * as sharp from 'sharp'; 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | import { R2Service } from '@/infra/r2/r2.service'; 6 | 7 | @Injectable() 8 | export class ImgHandlerService { 9 | @Inject() 10 | private readonly r2Service: R2Service; 11 | 12 | // 压缩图片为缩略图 13 | async generateThumbnail( 14 | imageBuffer: Buffer, 15 | fileName: string, 16 | type: 'cover' | 'detail' | 'avatar' | 'background' = 'cover', 17 | ) { 18 | const outputPath = path.join(__dirname, 'uploads', fileName + '-' + type + '-thumbnail.jpg'); 19 | 20 | // 确保目录存在 21 | if (!fs.existsSync(path.dirname(outputPath))) { 22 | fs.mkdirSync(path.dirname(outputPath), { recursive: true }); 23 | } 24 | 25 | // 获取图片的元数据 26 | const metadata = await sharp(imageBuffer).metadata(); 27 | 28 | let leastLength: number; 29 | if (type === 'cover') { 30 | const maxSide = Math.max(metadata.width, metadata.height); 31 | leastLength = maxSide < 400 ? maxSide : 400; 32 | } else if (type === 'detail') { 33 | const maxSide = Math.max(metadata.width, metadata.height); 34 | leastLength = maxSide < 800 ? maxSide : 800; 35 | } else if (type === 'background') { 36 | const maxSide = Math.max(metadata.width, metadata.height); 37 | leastLength = maxSide < 1200 ? maxSide : 1200; 38 | } else { 39 | leastLength = 200; 40 | } 41 | 42 | const newWidth = metadata.width > metadata.height ? null : leastLength; 43 | const newHeight = metadata.height > metadata.width ? null : leastLength; 44 | 45 | // 使用sharp调整尺寸并压缩质量 46 | const file = await sharp(imageBuffer) 47 | .resize(newWidth, newHeight) // 按比例调整尺寸 48 | .jpeg({ quality: 80 }) 49 | .toFile(outputPath); 50 | 51 | const targetPath = 'images' + outputPath.split('uploads')[1].replace(/\\/g, '/'); 52 | 53 | const resultUrl = await this.r2Service.uploadFileToR2(outputPath, targetPath); 54 | if (type === 'cover' || type === 'avatar') { 55 | return resultUrl; 56 | } else { 57 | return { 58 | url: resultUrl, 59 | width: file.width, 60 | height: file.height, 61 | size: file.size, 62 | }; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/modules/history/history.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, Inject, Query, Body } from '@nestjs/common'; 2 | import { HistoryService } from './history.service'; 3 | import { RequireLogin, UserInfo } from '@/common/decorators/login.decorator'; 4 | import { JwtUserData } from '@/common/guards/auth.guard'; 5 | import { HistoryItemVo } from './vo/history-item.vo'; 6 | 7 | @Controller('history') 8 | @RequireLogin() 9 | export class HistoryController { 10 | @Inject(HistoryService) 11 | private readonly historyService: HistoryService; 12 | 13 | @Get('list') // 分页获取用户指定日期的历史记录 14 | async getHistoryList( 15 | @UserInfo() userInfo: JwtUserData, 16 | @Query('date') date: string, 17 | @Query('pageSize') pageSize: number = 1, 18 | @Query('current') current: number = 10, 19 | ) { 20 | const { id } = userInfo; 21 | const historyList = await this.historyService.getHistoryListInPages( 22 | id, 23 | date, 24 | pageSize, 25 | current, 26 | ); 27 | return historyList.map((history) => new HistoryItemVo(history)); 28 | } 29 | 30 | @Get('count') // 获取用户指定日期的历史记录总数 31 | async getHistoryCount(@UserInfo() userInfo: JwtUserData, @Query('date') date: string) { 32 | const { id } = userInfo; 33 | const count = await this.historyService.getHistoryCount(id, date); 34 | return count; 35 | } 36 | 37 | @Post('new') // 新增用户历史记录 38 | async addHistory(@UserInfo() userInfo: JwtUserData, @Body('id') workId: string) { 39 | const { id } = userInfo; 40 | await this.historyService.addHistory(id, workId); 41 | return '新增记录成功!'; 42 | } 43 | 44 | @Post('delete') // 删除某条历史记录 45 | async deleteHistory(@UserInfo() userInfo: JwtUserData, @Query('id') historyId: string) { 46 | const { id } = userInfo; 47 | await this.historyService.deleteHistory(id, historyId); 48 | return '删除成功!'; 49 | } 50 | 51 | @Post('clear') // 清空用户历史记录 52 | async clearHistory(@UserInfo() userInfo: JwtUserData) { 53 | const { id } = userInfo; 54 | await this.historyService.clearHistory(id); 55 | return '清空成功!'; 56 | } 57 | 58 | @Get('search') // 根据作品名搜索历史记录 59 | async searchHistory(@UserInfo() userInfo: JwtUserData, @Query('keyword') keyword: string) { 60 | const { id } = userInfo; 61 | const historyList = await this.historyService.searchHistory(id, keyword); 62 | return historyList.map((history) => new HistoryItemVo(history)); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, ValidationPipe } from '@nestjs/common'; 2 | import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; 3 | import { ErrorFilter } from './common/error/error.filter'; 4 | import { ResponseInterceptor } from './common/interceptors/response.interceptor'; 5 | import { UserModule } from './modules/user/user.module'; 6 | import { IllustratorModule } from './modules/illustrator/illustrator.module'; 7 | import { IllustrationModule } from './modules/illustration/illustration.module'; 8 | import { LabelModule } from './modules/label/label.module'; 9 | import { CommentModule } from './modules/comment/comment.module'; 10 | import { HistoryModule } from './modules/history/history.module'; 11 | import { FavoriteModule } from './modules/favorite/favorite.module'; 12 | import { AuthGuard } from './common/guards/auth.guard'; 13 | // import { InvokeRecordInterceptor } from './common/interceptors/invoke-record.interceptor'; 14 | import { R2Module } from './infra/r2/r2.module'; 15 | import { ImgHandlerModule } from './services/img-handler/img-handler.module'; 16 | import { DatabaseModule } from './infra/database/database.module'; 17 | import { JwtModule } from './infra/jwt/jwt.module'; 18 | import { RedisModule } from './infra/redis/redis.module'; 19 | import { ConfigModule } from './infra/config/config.module'; 20 | import { EmailModule } from './infra/email/email.module'; 21 | 22 | @Module({ 23 | imports: [ 24 | // 基础设施 25 | ConfigModule, 26 | DatabaseModule, 27 | JwtModule, 28 | RedisModule, 29 | R2Module, 30 | EmailModule, 31 | 32 | // 数据库实体 33 | UserModule, 34 | IllustratorModule, 35 | IllustrationModule, 36 | LabelModule, 37 | CommentModule, 38 | HistoryModule, 39 | FavoriteModule, 40 | 41 | // 自定义服务 42 | ImgHandlerModule, 43 | ], 44 | providers: [ 45 | // 全局错误过滤器 46 | { 47 | provide: APP_FILTER, 48 | useClass: ErrorFilter, 49 | }, 50 | // 全局拦截器,统一返回格式 51 | { 52 | provide: APP_INTERCEPTOR, 53 | useClass: ResponseInterceptor, 54 | }, 55 | // 全局拦截器,用于记录日志 56 | // { 57 | // provide: APP_INTERCEPTOR, 58 | // useClass: InvokeRecordInterceptor, 59 | // }, 60 | // 全局管道,验证数据 61 | { 62 | provide: APP_PIPE, 63 | useClass: ValidationPipe, 64 | }, 65 | // 全局守卫 66 | { 67 | provide: APP_GUARD, 68 | useClass: AuthGuard, 69 | }, 70 | ], 71 | }) 72 | export class AppModule {} 73 | -------------------------------------------------------------------------------- /src/modules/comment/comment.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { Comment } from './entities/comment.entity'; 5 | import type { CreateCommentDto } from './dto/create-comment.dto'; 6 | import { User } from '../user/entities/user.entity'; 7 | import { Illustration } from '../illustration/entities/illustration.entity'; 8 | import { hanaError } from '@/common/error/hanaError'; 9 | import { IllustrationService } from '../illustration/illustration.service'; 10 | 11 | @Injectable() 12 | export class CommentService { 13 | @InjectRepository(Comment) 14 | private readonly commentRepository: Repository; 15 | 16 | @Inject(IllustrationService) 17 | private readonly illustrationService: IllustrationService; 18 | 19 | // 获取某个作品的评论列表 20 | async getCommentList(id: string) { 21 | return this.commentRepository.find({ 22 | where: { 23 | illustration: { id }, 24 | level: 0, 25 | }, 26 | relations: ['user', 'replies', 'replies.user', 'replies.replyToUser'], 27 | }); 28 | } 29 | 30 | // 发布评论 31 | async createComment(userId: string, createCommentDto: CreateCommentDto) { 32 | const { id: workId, content, replyInfo } = createCommentDto; 33 | 34 | const user = new User(); 35 | user.id = userId; 36 | 37 | const work = new Illustration(); 38 | work.id = workId; 39 | 40 | const comment = new Comment(); 41 | comment.content = content; 42 | comment.user = user; 43 | comment.illustration = work; 44 | 45 | if (!replyInfo) { 46 | // 1. 一级评论 47 | comment.level = 0; 48 | } else { 49 | // 2. 二级评论 50 | comment.level = 1; 51 | const replyComment = new Comment(); 52 | replyComment.id = replyInfo.replyCommentId; 53 | comment.replyTo = replyComment; 54 | // 3. 二级评论回复的用户 55 | if (replyInfo.replyUserId) { 56 | const replyUser = new User(); 57 | replyUser.id = replyInfo.replyUserId; 58 | comment.replyToUser = replyUser; 59 | } 60 | } 61 | await this.commentRepository.save(comment); 62 | await this.illustrationService.updateCommentCount(workId, 'increase'); 63 | return; 64 | } 65 | 66 | // 删除某条评论 67 | async deleteComment(userId: string, commentId: string) { 68 | const comment = await this.commentRepository.findOne({ 69 | where: { id: commentId }, 70 | relations: ['user', 'replies', 'illustration'], 71 | }); 72 | if (!comment) throw new hanaError(10701); 73 | if (comment.user.id !== userId) throw new hanaError(10702); 74 | await this.commentRepository.remove(comment); 75 | await this.illustrationService.updateCommentCount( 76 | comment.illustration.id, 77 | 'decrease', 78 | comment.level === 0 ? comment.replies.length + 1 : 1, 79 | ); 80 | return; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/modules/illustrator/illustrator.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Inject, Post, Query } from '@nestjs/common'; 2 | import { IllustratorService } from './illustrator.service'; 3 | import { NewIllustratorDto } from './dto/new-illustrator.dto'; 4 | import { EditIllustratorDto } from './dto/edit-illustrator.dto'; 5 | import { IllustratorDetailVo } from './vo/illustrator-detail.vo'; 6 | import { RequireLogin, UserInfo } from '@/common/decorators/login.decorator'; 7 | import { JwtUserData } from '@/common/guards/auth.guard'; 8 | import { IllustrationItemVO } from '../illustration/vo/illustration-item.vo'; 9 | import { UserService } from '../user/user.service'; 10 | 11 | @Controller('illustrator') 12 | export class IllustratorController { 13 | @Inject(IllustratorService) 14 | private readonly illustratorService: IllustratorService; 15 | 16 | @Inject(UserService) 17 | private readonly userService: UserService; 18 | 19 | @Post('new') // 新增插画家(转载作品需要填写) 20 | @RequireLogin() 21 | async createIllustrator(@Body() newIllustratorDto: NewIllustratorDto) { 22 | await this.illustratorService.createItem(newIllustratorDto); 23 | return '创建成功!'; 24 | } 25 | 26 | @Post('edit') // 修改插画家信息 27 | @RequireLogin() 28 | async editIllustrator(@Query('id') id: string, @Body() editIllustratorDto: EditIllustratorDto) { 29 | await this.illustratorService.editItem(id, editIllustratorDto); 30 | return '修改成功!'; 31 | } 32 | 33 | @Get('list') // 分页获取插画家列表 34 | @RequireLogin() 35 | async getIllustratorList(@Query('current') current: number, @Query('pageSize') size: number) { 36 | const illustrators = await this.illustratorService.getIllustratorList(current, size); 37 | return illustrators.map((illustrator) => new IllustratorDetailVo(illustrator)); 38 | } 39 | 40 | @Get('search') // 搜索插画家 41 | @RequireLogin() 42 | async searchIllustrators(@Query('keyword') keyword: string) { 43 | const illustrators = await this.illustratorService.searchIllustrators(keyword); 44 | return illustrators.map((illustrator) => new IllustratorDetailVo(illustrator)); 45 | } 46 | 47 | @Get('detail') // 获取插画家详情信息 48 | async getIllustratorDetail(@Query('id') id: string) { 49 | const illustrator = await this.illustratorService.findItemById(id); 50 | return new IllustratorDetailVo(illustrator); 51 | } 52 | 53 | @Get('works') // 分页获取该插画家的作品列表 54 | async getIllustratorWorksInPages( 55 | @UserInfo() userInfo: JwtUserData, 56 | @Query('id') id: string, 57 | @Query('current') current: number, 58 | @Query('pageSize') size: number, 59 | ) { 60 | const works = await this.illustratorService.getIllustratorWorksInPages(id, current, size); 61 | return await Promise.all( 62 | works.map( 63 | async (work) => 64 | new IllustrationItemVO( 65 | work, 66 | userInfo ? await this.userService.isLiked(userInfo.id, work.id) : false, 67 | ), 68 | ), 69 | ); 70 | } 71 | 72 | @Get('works-id') // 获取该插画家的作品id列表 73 | async getIllustratorWorksId(@Query('id') id: string) { 74 | return await this.illustratorService.getIllustratorWorksIdList(id); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/modules/history/history.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { History } from './entities/history.entity'; 4 | import { Repository } from 'typeorm'; 5 | 6 | @Injectable() 7 | export class HistoryService { 8 | @InjectRepository(History) 9 | private historyRepository: Repository; 10 | 11 | // 分页获取指定日期的历史记录 12 | async getHistoryListInPages(userId: string, date: string, pageSize: number, current: number) { 13 | const startDate = new Date(date); 14 | const endDate = new Date(startDate); 15 | endDate.setDate(startDate.getDate() + 1); 16 | 17 | return await this.historyRepository 18 | .createQueryBuilder('history') 19 | .leftJoinAndSelect('history.user', 'user') 20 | .leftJoinAndSelect('history.illustration', 'illustration') 21 | .leftJoinAndSelect('illustration.user', 'author') 22 | .where('user.id = :userId', { userId }) 23 | .andWhere('history.lastTime >= :startDate', { startDate }) 24 | .andWhere('history.lastTime < :endDate', { endDate }) 25 | .take(pageSize) 26 | .skip(pageSize * (current - 1)) 27 | .orderBy('history.lastTime', 'DESC') 28 | .getMany(); 29 | } 30 | 31 | // 获取指定日期的历史记录总数 32 | async getHistoryCount(userId: string, date: string) { 33 | const startDate = new Date(date); 34 | const endDate = new Date(startDate); 35 | endDate.setDate(startDate.getDate() + 1); 36 | 37 | return await this.historyRepository 38 | .createQueryBuilder('history') 39 | .where('history.user.id = :userId', { userId }) 40 | .andWhere('history.lastTime >= :startDate', { startDate }) 41 | .andWhere('history.lastTime < :endDate', { endDate }) 42 | .getCount(); 43 | } 44 | 45 | // 新增/更新用户浏览记录 46 | async addHistory(userId: string, workId: string) { 47 | const savedHistory = await this.historyRepository.findOneBy({ 48 | user: { id: userId }, 49 | illustration: { id: workId }, 50 | }); 51 | if (savedHistory) { 52 | return await this.historyRepository.save({ 53 | ...savedHistory, 54 | lastTime: new Date(), 55 | }); 56 | } 57 | return await this.historyRepository.save({ 58 | user: { id: userId }, 59 | illustration: { id: workId }, 60 | }); 61 | } 62 | 63 | // 删除某条历史记录 64 | async deleteHistory(userId: string, historyId: string) { 65 | return await this.historyRepository.delete({ id: historyId, user: { id: userId } }); 66 | } 67 | 68 | // 清空用户历史记录 69 | async clearHistory(userId: string) { 70 | return await this.historyRepository.delete({ user: { id: userId } }); 71 | } 72 | 73 | // 根据作品名搜索历史记录 74 | async searchHistory(userId: string, keyword: string) { 75 | return await this.historyRepository 76 | .createQueryBuilder('history') 77 | .leftJoinAndSelect('history.user', 'user') 78 | .leftJoinAndSelect('history.illustration', 'illustration') 79 | .leftJoinAndSelect('illustration.user', 'author') 80 | .where('user.id = :userId', { userId }) 81 | .andWhere('illustration.name LIKE :name', { name: `%${keyword}%` }) 82 | .orderBy('history.lastTime', 'DESC') 83 | .getMany(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "picals-backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build && npm run copy", 10 | "copy": "ts-node scripts/copy.ts", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/src/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "format": "prettier --write \"src/**/*.ts\"", 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 | "script:seed": "node -r ./tsconfig-paths-bootstrap.cjs -r ts-node/register scripts/seed-data.ts", 23 | "script:upload": "node -r ./tsconfig-paths-bootstrap.cjs -r ts-node/register scripts/upload-dir.ts", 24 | "script:password": "node -r ./tsconfig-paths-bootstrap.cjs -r ts-node/register scripts/password.ts" 25 | }, 26 | "dependencies": { 27 | "@aws-sdk/client-s3": "^3.817.0", 28 | "@nestjs/common": "^11.0.10", 29 | "@nestjs/config": "^4.0.2", 30 | "@nestjs/core": "^11.0.10", 31 | "@nestjs/jwt": "^11.0.0", 32 | "@nestjs/mapped-types": "*", 33 | "@nestjs/platform-express": "^11.0.10", 34 | "@nestjs/typeorm": "^11.0.0", 35 | "argon2": "^0.43.0", 36 | "axios": "^1.9.0", 37 | "cache-manager": "^6.4.3", 38 | "class-transformer": "^0.5.1", 39 | "class-validator": "^0.14.2", 40 | "dayjs": "^1.11.13", 41 | "ioredis": "^5.7.0", 42 | "multer": "2.0.0", 43 | "mysql2": "^3.14.1", 44 | "nodemailer": "^7.0.3", 45 | "puppeteer": "^24.9.0", 46 | "reflect-metadata": "^0.2.2", 47 | "rxjs": "^7.8.2", 48 | "sharp": "^0.34.2", 49 | "typeorm": "^0.3.24" 50 | }, 51 | "devDependencies": { 52 | "@eslint/js": "^9.27.0", 53 | "@nestjs/cli": "^11.0.7", 54 | "@nestjs/schematics": "^11.0.5", 55 | "@nestjs/testing": "^11.0.10", 56 | "@types/express": "^5.0.2", 57 | "@types/jest": "^29.5.14", 58 | "@types/multer": "^1.4.12", 59 | "@types/node": "^22.15.21", 60 | "@types/nodemailer": "^6.4.17", 61 | "@types/shelljs": "^0.8.16", 62 | "@types/supertest": "^6.0.3", 63 | "@typescript-eslint/eslint-plugin": "^8.32.1", 64 | "@typescript-eslint/parser": "^8.32.1", 65 | "eslint": "^9.27.0", 66 | "eslint-config-prettier": "^10.1.5", 67 | "eslint-plugin-prettier": "^5.4.0", 68 | "jest": "^29.7.0", 69 | "prettier": "^3.5.3", 70 | "shelljs": "^0.10.0", 71 | "source-map-support": "^0.5.21", 72 | "supertest": "^7.1.1", 73 | "ts-jest": "^29.3.4", 74 | "ts-loader": "^9.5.2", 75 | "ts-node": "^10.9.2", 76 | "tsconfig-paths": "^4.2.0", 77 | "typescript": "^5.8.3" 78 | }, 79 | "jest": { 80 | "moduleFileExtensions": [ 81 | "js", 82 | "json", 83 | "ts" 84 | ], 85 | "rootDir": "src", 86 | "testRegex": ".*\\.spec\\.ts$", 87 | "transform": { 88 | "^.+\\.(t|j)s$": "ts-jest" 89 | }, 90 | "collectCoverageFrom": [ 91 | "**/*.(t|j)s" 92 | ], 93 | "coverageDirectory": "../coverage", 94 | "testEnvironment": "node" 95 | }, 96 | "pnpm": { 97 | "onlyBuiltDependencies": [ 98 | "@nestjs/core", 99 | "argon2", 100 | "puppeteer", 101 | "sharp" 102 | ] 103 | }, 104 | "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" 105 | } 106 | -------------------------------------------------------------------------------- /src/modules/illustration/vo/illustration-detail.vo.ts: -------------------------------------------------------------------------------- 1 | import type { Illustration } from '@/modules/illustration/entities/illustration.entity'; 2 | import * as dayjs from 'dayjs'; 3 | import type { Image } from '../entities/image.entity'; 4 | 5 | export interface LabelItem { 6 | /** 7 | * 标签颜色,由后台进行随机不重复的颜色生成 8 | */ 9 | color: string; 10 | /** 11 | * 标签id 12 | */ 13 | id: string; 14 | /** 15 | * 标签封面图片,当该标签的作品数达到一定量级后,由管理员在后台进行上传,默认就是随机生成的纯色背景图 16 | */ 17 | img: null | string; 18 | /** 19 | * 标签名称 20 | */ 21 | name: string; 22 | } 23 | 24 | export class IllustrationDetailVO { 25 | /** 26 | * 作者id 27 | */ 28 | authorId: string; 29 | /** 30 | * 被收藏次数 31 | */ 32 | collectNum: number; 33 | /** 34 | * 评论个数 35 | */ 36 | commentNum: number; 37 | /** 38 | * 创建日期 39 | */ 40 | createdDate: string; 41 | /** 42 | * 作品id 43 | */ 44 | id: string; 45 | /** 46 | * 作品图片url列表 47 | */ 48 | imgList: string[]; 49 | /** 50 | * 图片详细信息列表 51 | */ 52 | images: Image[]; 53 | /** 54 | * 作品简介 55 | */ 56 | intro: string; 57 | /** 58 | * 是否是AI生成作品 59 | */ 60 | isAIGenerated: boolean; 61 | /** 62 | * 是否已经收藏 63 | */ 64 | isCollected: boolean; 65 | /** 66 | * 已经被收藏的收藏夹id,如果没有被收藏则不传 67 | */ 68 | favoriteIds?: string[]; 69 | /** 70 | * 用户是否已经喜欢 71 | */ 72 | isLiked: boolean; 73 | /** 74 | * 转载作品。0-原创,1-转载,2-合集 75 | */ 76 | reprintType: number; 77 | /** 78 | * 标签列表 79 | */ 80 | labels: LabelItem[]; 81 | /** 82 | * 被喜欢次数 83 | */ 84 | likeNum: number; 85 | /** 86 | * 作品名称 87 | */ 88 | name: string; 89 | /** 90 | * 是否打开评论 91 | */ 92 | openComment: boolean; 93 | /** 94 | * 更新日期 95 | */ 96 | updatedDate: string; 97 | /** 98 | * 被浏览次数 99 | */ 100 | viewNum: number; 101 | /** 102 | *原作品地址(转载作品) 103 | */ 104 | workUrl?: string; 105 | /** 106 | * 插画家信息(转载作品) 107 | */ 108 | illustrator?: { 109 | id: string; 110 | name: string; 111 | intro: string; 112 | avatar: string; 113 | homeUrl: string; 114 | workCount: number; 115 | }; 116 | 117 | constructor(userId: string, illustration: Illustration, isLiked: boolean, isCollected: boolean) { 118 | this.id = illustration.id; 119 | this.authorId = illustration.user.id; 120 | this.imgList = illustration.imgList; 121 | this.images = illustration.images; 122 | this.intro = illustration.intro; 123 | this.isAIGenerated = illustration.isAIGenerated; 124 | this.isCollected = isCollected; 125 | this.isLiked = isLiked; 126 | this.labels = illustration.labels.map((label) => ({ 127 | color: label.color, 128 | id: label.id, 129 | img: label.cover, 130 | name: label.value, 131 | })); 132 | this.name = illustration.name; 133 | this.openComment = illustration.openComment; 134 | this.updatedDate = dayjs(illustration.updatedTime).format('YYYY-MM-DD HH:mm:ss'); 135 | this.createdDate = dayjs(illustration.createdTime).format('YYYY-MM-DD HH:mm:ss'); 136 | this.likeNum = illustration.likeCount; 137 | this.collectNum = illustration.collectCount; 138 | this.commentNum = illustration.commentCount; 139 | this.reprintType = illustration.reprintType; 140 | this.viewNum = illustration.viewCount; 141 | if (this.reprintType !== 0 && illustration.illustrator) 142 | this.illustrator = { 143 | id: illustration.illustrator.id, 144 | name: illustration.illustrator.name, 145 | intro: illustration.illustrator.intro, 146 | avatar: illustration.illustrator.avatar, 147 | homeUrl: illustration.illustrator.homeUrl, 148 | workCount: illustration.illustrator.workCount, 149 | }; 150 | if (this.reprintType === 1) this.workUrl = illustration.workUrl; 151 | if (isCollected) 152 | this.favoriteIds = illustration.favorites 153 | .filter((favorite) => favorite.user.id === userId) 154 | .map((favorite) => favorite.id); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/modules/illustrator/illustrator.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Illustrator } from './entities/illustrator.entity'; 4 | import { Repository } from 'typeorm'; 5 | import type { NewIllustratorDto } from './dto/new-illustrator.dto'; 6 | import { hanaError } from '@/common/error/hanaError'; 7 | import type { EditIllustratorDto } from './dto/edit-illustrator.dto'; 8 | import { Illustration } from '../illustration/entities/illustration.entity'; 9 | import { Like } from 'typeorm'; 10 | import { downloadFile, suffixGenerator } from 'src/utils'; 11 | import { ImgHandlerService } from '@/services/img-handler/img-handler.service'; 12 | 13 | @Injectable() 14 | export class IllustratorService { 15 | @Inject() 16 | private readonly imgHandlerService: ImgHandlerService; 17 | 18 | @InjectRepository(Illustrator) 19 | private readonly illustratorRepository: Repository; 20 | 21 | @InjectRepository(Illustration) 22 | private readonly illustrationRepository: Repository; 23 | 24 | // 根据 id 查找插画家 25 | async findItemById(id: string) { 26 | return await this.illustratorRepository.findOne({ where: { id } }); 27 | } 28 | 29 | // 根据名字查找插画家 30 | async findItemByName(name: string) { 31 | return await this.illustratorRepository.findOne({ where: { name } }); 32 | } 33 | 34 | // 搜索插画家 35 | async searchIllustrators(keyword: string) { 36 | return await this.illustratorRepository.find({ where: { name: Like(`%${keyword}%`) } }); 37 | } 38 | 39 | // 创建插画家 40 | async createItem(newIllustratorDto: NewIllustratorDto) { 41 | const existedIllustrator = await this.findItemByName(newIllustratorDto.name); 42 | if (existedIllustrator) throw new hanaError(10902); 43 | 44 | if (newIllustratorDto.avatar) { 45 | const imgBuffer = await downloadFile(newIllustratorDto.avatar); 46 | const fileName = suffixGenerator('little-avatar.jpg'); 47 | const thumbnail = (await this.imgHandlerService.generateThumbnail( 48 | imgBuffer, 49 | fileName, 50 | 'avatar', 51 | )) as string; 52 | return await this.illustratorRepository.save({ 53 | ...newIllustratorDto, 54 | littleAvatar: thumbnail, 55 | }); 56 | } 57 | 58 | return await this.illustratorRepository.save(newIllustratorDto); 59 | } 60 | 61 | // 修改插画家信息 62 | async editItem(id: string, editIllustratorDto: EditIllustratorDto) { 63 | const illustrator = await this.findItemById(id); 64 | if (!illustrator) throw new hanaError(10901); 65 | 66 | if (editIllustratorDto.avatar && illustrator.avatar !== editIllustratorDto.avatar) { 67 | const imgBuffer = await downloadFile(editIllustratorDto.avatar); 68 | const fileName = suffixGenerator('little-avatar.jpg'); 69 | const thumbnail = (await this.imgHandlerService.generateThumbnail( 70 | imgBuffer, 71 | fileName, 72 | 'avatar', 73 | )) as string; 74 | return await this.illustratorRepository.save({ 75 | ...illustrator, 76 | ...editIllustratorDto, 77 | littleAvatar: thumbnail, 78 | }); 79 | } 80 | 81 | return await this.illustratorRepository.save({ ...illustrator, ...editIllustratorDto }); 82 | } 83 | 84 | // 分页获取插画家列表 85 | async getIllustratorList(current: number, size: number) { 86 | return await this.illustratorRepository.find({ 87 | take: size, 88 | skip: (current - 1) * size, 89 | }); 90 | } 91 | 92 | // 分页获取该插画家的作品列表 93 | async getIllustratorWorksInPages(id: string, current: number, size: number) { 94 | return await this.illustrationRepository.find({ 95 | where: { illustrator: { id } }, 96 | relations: ['user'], 97 | order: { createdTime: 'DESC' }, 98 | take: size, 99 | skip: (current - 1) * size, 100 | }); 101 | } 102 | 103 | // 获取该插画家的作品id列表 104 | async getIllustratorWorksIdList(id: string) { 105 | const works = await this.illustrationRepository.find({ 106 | where: { illustrator: { id } }, 107 | order: { createdTime: 'DESC' }, 108 | }); 109 | return works.map((work) => work.id); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/modules/illustration/entities/illustration.entity.ts: -------------------------------------------------------------------------------- 1 | // /src/illustration/entities/illustration.entity.ts 2 | // 插画实体 3 | 4 | import { Image } from '@/modules/illustration/entities/image.entity'; 5 | import { Comment } from '@/modules/comment/entities/comment.entity'; 6 | import { Favorite } from '@/modules/favorite/entities/favorite.entity'; 7 | import { History } from '@/modules/history/entities/history.entity'; 8 | import { Illustrator } from '@/modules/illustrator/entities/illustrator.entity'; 9 | import { Label } from '@/modules/label/entities/label.entity'; 10 | import { User } from '@/modules/user/entities/user.entity'; 11 | import { LikeWorks } from '@/modules/user/entities/like-works.entity'; 12 | import { 13 | Column, 14 | CreateDateColumn, 15 | Entity, 16 | Index, 17 | JoinColumn, 18 | JoinTable, 19 | ManyToMany, 20 | ManyToOne, 21 | OneToMany, 22 | PrimaryColumn, 23 | UpdateDateColumn, 24 | } from 'typeorm'; 25 | 26 | @Entity({ 27 | name: 'illustrations', 28 | }) 29 | export class Illustration { 30 | @PrimaryColumn({ 31 | type: 'uuid', 32 | generated: 'uuid', 33 | comment: '插画id,采用uuid的形式', 34 | }) 35 | id: string; 36 | 37 | @Column({ 38 | type: 'varchar', 39 | length: 63, 40 | comment: '插画名', 41 | default: '', 42 | }) 43 | name: string; 44 | 45 | @Column({ 46 | type: 'varchar', 47 | length: 2047, 48 | comment: '插画简介', 49 | default: '', 50 | }) 51 | intro: string; 52 | 53 | @Column({ 54 | type: 'tinyint', 55 | comment: '转载类型。0-原创,1-转载,2-合集', 56 | }) 57 | reprintType: number; 58 | 59 | @Column({ 60 | type: 'boolean', 61 | comment: '是否开启评论', 62 | }) 63 | openComment: boolean; 64 | 65 | @Column({ 66 | type: 'boolean', 67 | comment: '是否为AI生成作品', 68 | }) 69 | isAIGenerated: boolean; 70 | 71 | @Column('simple-array', { 72 | comment: '插画的作品原图地址列表', 73 | }) 74 | imgList: string[]; 75 | 76 | @OneToMany(() => Image, (image) => image.illustration) 77 | images: Image[]; 78 | 79 | @Column({ 80 | type: 'varchar', 81 | length: 255, 82 | comment: '插画封面URL', 83 | }) 84 | cover: string; 85 | 86 | @Column({ 87 | type: 'varchar', 88 | length: 255, 89 | comment: '插画原图URL', 90 | nullable: true, 91 | name: 'original_url', 92 | }) 93 | workUrl: string; 94 | 95 | @Column({ 96 | type: 'int', 97 | comment: '插画点赞数量', 98 | default: 0, 99 | name: 'like_count', 100 | }) 101 | likeCount: number; 102 | 103 | @Column({ 104 | type: 'int', 105 | comment: '插画观看数量', 106 | default: 0, 107 | name: 'view_count', 108 | }) 109 | viewCount: number; 110 | 111 | @Column({ 112 | type: 'int', 113 | comment: '插画收藏数量', 114 | default: 0, 115 | name: 'collect_count', 116 | }) 117 | collectCount: number; 118 | 119 | @Column({ 120 | type: 'int', 121 | comment: '插画评论数量', 122 | default: 0, 123 | name: 'comment_count', 124 | }) 125 | commentCount: number; 126 | 127 | @Column({ 128 | type: 'tinyint', 129 | comment: '插画状态。0-审核中,1-已发布,2-已删除', 130 | name: 'status', 131 | default: 0, 132 | }) 133 | status: number; 134 | 135 | @CreateDateColumn({ 136 | type: 'timestamp', 137 | comment: '插画创建时间', 138 | name: 'created_time', 139 | }) 140 | @Index() 141 | createdTime: Date; 142 | 143 | @UpdateDateColumn({ 144 | type: 'timestamp', 145 | comment: '插画更新时间', 146 | name: 'updated_time', 147 | }) 148 | updatedTime: Date; 149 | 150 | @ManyToOne(() => User, (user) => user.illustrations, { 151 | nullable: true, 152 | onDelete: 'SET NULL', 153 | }) 154 | @JoinColumn({ name: 'user_id' }) 155 | user: User; 156 | 157 | @ManyToOne(() => Illustrator, (illustrator) => illustrator.illustrations, { 158 | nullable: true, 159 | onDelete: 'SET NULL', 160 | }) 161 | @JoinColumn({ name: 'illustrator_id' }) 162 | illustrator: Illustrator; 163 | 164 | @ManyToMany(() => Label, (label) => label.illustrations, { 165 | cascade: true, 166 | onDelete: 'CASCADE', 167 | }) 168 | @JoinTable() 169 | labels: Label[]; 170 | 171 | @OneToMany(() => LikeWorks, (likeWorks) => likeWorks.illustration) 172 | likeUsers: LikeWorks[]; 173 | 174 | @OneToMany(() => Comment, (comment) => comment.illustration) 175 | comments: Comment[]; 176 | 177 | @OneToMany(() => History, (history) => history.illustration) 178 | histories: History[]; 179 | 180 | @ManyToMany(() => Favorite, (favorite) => favorite.illustrations, { 181 | cascade: true, 182 | onDelete: 'CASCADE', 183 | }) 184 | @JoinTable() 185 | favorites: Favorite[]; 186 | } 187 | -------------------------------------------------------------------------------- /src/modules/label/label.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Label } from './entities/label.entity'; 4 | import { In, Repository } from 'typeorm'; 5 | import { Illustration } from '../illustration/entities/illustration.entity'; 6 | import { REDIS_CLIENT } from '@/infra/redis/redis.module'; 7 | import { Like } from 'typeorm'; 8 | import { shuffleArray } from '@/utils/shuffleArray'; 9 | import { Redis } from 'ioredis'; 10 | 11 | // 生成随机的hex颜色 12 | const randomColor = () => { 13 | return `#${Math.floor(Math.random() * 16777215) 14 | .toString(16) 15 | .padStart(6, '0')}`; 16 | }; 17 | 18 | @Injectable() 19 | export class LabelService { 20 | @Inject(REDIS_CLIENT) 21 | private readonly redisClient: Redis; 22 | 23 | @InjectRepository(Label) 24 | private readonly labelRepository: Repository