Commit 867eae47 authored by nanahira's avatar nanahira

first

parents
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
/data
/output
/config.yaml
.git*
Dockerfile
.dockerignore
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
/data
/output
/config.yaml
\ No newline at end of file
stages:
- build
- combine
- deploy
variables:
GIT_DEPTH: "1"
CONTAINER_TEST_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
CONTAINER_TEST_ARM_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-arm
CONTAINER_TEST_X86_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-x86
CONTAINER_RELEASE_IMAGE: $CI_REGISTRY_IMAGE:latest
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
build-x86:
stage: build
tags:
- docker
script:
- TARGET_IMAGE=$CONTAINER_TEST_X86_IMAGE
- docker build --pull -t $TARGET_IMAGE .
- docker push $TARGET_IMAGE
build-arm:
stage: build
tags:
- docker-arm
script:
- TARGET_IMAGE=$CONTAINER_TEST_ARM_IMAGE
- docker build --pull -t $TARGET_IMAGE .
- docker push $TARGET_IMAGE
combine:
stage: combine
tags:
- docker
script:
- TARGET_IMAGE=$CONTAINER_TEST_IMAGE
- SOURCE_IMAGE_2=$CONTAINER_TEST_ARM_IMAGE
- SOURCE_IMAGE_1=$CONTAINER_TEST_X86_IMAGE
- docker pull $SOURCE_IMAGE_1
- docker pull $SOURCE_IMAGE_2
- docker manifest create $TARGET_IMAGE --amend $SOURCE_IMAGE_1 --amend
$SOURCE_IMAGE_2
- docker manifest push $TARGET_IMAGE
deploy_latest:
stage: deploy
tags:
- docker
script:
- TARGET_IMAGE=$CONTAINER_RELEASE_IMAGE
- SOURCE_IMAGE=$CONTAINER_TEST_IMAGE
- docker pull $SOURCE_IMAGE
- docker tag $SOURCE_IMAGE $TARGET_IMAGE
- docker push $TARGET_IMAGE
only:
- master
deploy_tag:
stage: deploy
tags:
- docker
script:
- TARGET_IMAGE=$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
- SOURCE_IMAGE=$CONTAINER_TEST_IMAGE
- docker pull $SOURCE_IMAGE
- docker tag $SOURCE_IMAGE $TARGET_IMAGE
- docker push $TARGET_IMAGE
only:
- tags
/install-npm.sh
.git*
/data
/output
/config.yaml
.idea
.dockerignore
Dockerfile
/src
{
"singleQuote": true,
"trailingComma": "all"
}
\ No newline at end of file
FROM node:buster-slim as base
LABEL Author="Nanahira <nanahira@momobako.com>"
RUN apt update && apt -y install libmagick++-dev python3 build-essential libpq-dev && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
WORKDIR /usr/src/app
COPY ./package*.json ./
FROM base as builder
RUN npm ci && npm cache clean --force
COPY . ./
RUN npm run build
FROM base
ENV NODE_ENV production
RUN npm ci && npm cache clean --force
COPY --from=builder /usr/src/app/dist ./dist
ENV NODE_PG_FORCE_NATIVE=true
EXPOSE 3000
CMD [ "npm", "run", "start:prod" ]
This diff is collapsed.
# App name
App description.
## Environment
* `DB_HOST` `DB_PORT` `DB_USER` `DB_PASS` `DB_NAME` Database configs.
## Installation
```bash
$ npm install
```
## Running the app
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## License
AGPLv3
#!/bin/bash
npm install --save \
class-validator \
class-transformer \
@nestjs/swagger \
swagger-ui-express \
typeorm \
@nestjs/typeorm \
@nestjs/config \
pg \
pg-native \
reflect-metadata
npm install --save-dev \
@types/express \
@types/multer
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/swagger"]
}
}
This diff is collapsed.
{
"name": "avatar-handler-re",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/axios": "^0.0.4",
"@nestjs/common": "^8.0.0",
"@nestjs/config": "^1.1.6",
"@nestjs/core": "^8.0.0",
"@nestjs/platform-express": "^8.0.0",
"@nestjs/swagger": "^5.1.5",
"@nestjs/typeorm": "^8.0.2",
"@xzeldon/imagemagick-native": "^1.9.5",
"class-transformer": "^0.4.0",
"class-validator": "^0.13.2",
"lodash": "^4.17.21",
"pg": "^8.7.1",
"pg-native": "^3.0.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"swagger-ui-express": "^4.3.0",
"typeorm": "^0.2.41"
},
"devDependencies": {
"@nestjs/cli": "^8.0.0",
"@nestjs/schematics": "^8.0.0",
"@nestjs/testing": "^8.0.0",
"@types/express": "^4.17.13",
"@types/jest": "^27.0.1",
"@types/lodash": "^4.14.178",
"@types/multer": "^1.4.7",
"@types/node": "^16.0.0",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^4.28.2",
"@typescript-eslint/parser": "^4.28.2",
"eslint": "^7.30.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^3.4.0",
"jest": "^27.0.6",
"prettier": "^2.3.2",
"supertest": "^6.1.3",
"ts-jest": "^27.0.3",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
"tsconfig-paths": "^3.10.1",
"typescript": "^4.3.5"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should be defined', () => {
expect(appController).toBeDefined();
});
});
});
import {
Controller,
Get,
Header,
Param,
ParseIntPipe,
StreamableFile,
} from '@nestjs/common';
import { AppService } from './app.service';
import { ApiOperation, ApiParam } from '@nestjs/swagger';
import { BlankReturnMessageDto } from './dto/ReturnMessage.dto';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('user_avatar/ygobbs.com/:username/:size/:filename.png')
@Header('Cache-Control', 'public, max-age=600')
@Header('Content-Type', 'image/png')
@ApiOperation({ summary: '获取裁剪后的用户头像' })
@ApiParam({ name: 'username', description: '用户名' })
@ApiParam({ name: 'size', description: '头像尺寸', type: 'number' })
@ApiParam({ name: 'filename', description: '头像文件名' })
async getImage(
@Param('username') username: string,
@Param('size', ParseIntPipe) size: number,
) {
if (size < 8 || size > 1000) {
throw new BlankReturnMessageDto(400, 'Invalid size');
}
const buffer = await this.appService.getImage(username, size);
return new StreamableFile(buffer);
}
}
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { HttpModule } from '@nestjs/axios';
import { AvatarApiService } from './avatar-api/avatar-api.service';
import { ImageService } from './image/image.service';
import { Avatar } from './entities/Avatar.entity';
@Module({
imports: [
HttpModule,
ConfigModule.forRoot({
isGlobal: true,
}),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: async (config: ConfigService) => {
return {
type: 'postgres',
entities: [Avatar], // entities here
synchronize: !config.get('DB_NO_INIT'),
host: config.get('DB_HOST'),
port: parseInt(config.get('DB_PORT')) || 5432,
username: config.get('DB_USER'),
password: config.get('DB_PASS'),
database: config.get('DB_NAME'),
supportBigNumbers: true,
bigNumberStrings: false,
};
},
}),
],
controllers: [AppController],
providers: [AppService, AvatarApiService, ImageService],
})
export class AppModule {}
import { Connection } from 'typeorm';
import { InjectConnection } from '@nestjs/typeorm';
import { Injectable, ConsoleLogger } from '@nestjs/common';
import { ImageService } from './image/image.service';
import { AvatarApiService } from './avatar-api/avatar-api.service';
import { Avatar } from './entities/Avatar.entity';
@Injectable()
export class AppService extends ConsoleLogger {
constructor(
@InjectConnection()
private db: Connection,
private image: ImageService,
private avatarApi: AvatarApiService,
) {
super('app');
}
async getImage(username: string, size: number) {
const url = await this.avatarApi.getURLFromUsername(username);
const existingAvatar = await this.db.getRepository(Avatar).findOne({
where: {
url,
size,
},
});
if (existingAvatar) {
return existingAvatar.toBuffer();
}
let originalAvatarBuffer: Buffer;
const originalAvatar = await this.db.getRepository(Avatar).findOne({
where: {
url,
size: 0,
},
});
if (originalAvatar) {
originalAvatarBuffer = originalAvatar.toBuffer();
} else {
originalAvatarBuffer = await this.avatarApi.downloadAvatar(url);
try {
await this.db.getRepository(Avatar).save({
url,
size: 0,
buffer: originalAvatarBuffer,
});
} catch (e) {
this.error(`Fail to save original avatar: ${e.message}`);
}
}
const avatarBuffer = await this.image.resize(originalAvatarBuffer, size);
try {
const avatar = new Avatar();
avatar.url = url;
avatar.size = size;
avatar.fromBuffer(avatarBuffer);
await this.db.getRepository(Avatar).save(avatar);
} catch (e) {
this.error(`Fail to save avatar: ${e.message}`);
}
return avatarBuffer;
}
}
import { Test, TestingModule } from '@nestjs/testing';
import { AvatarApiService } from './avatar-api.service';
describe('AvatarApiService', () => {
let service: AvatarApiService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AvatarApiService],
}).compile();
service = module.get<AvatarApiService>(AvatarApiService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
import { ConsoleLogger, Injectable } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { lastValueFrom } from 'rxjs';
import _ from 'lodash';
import { BlankReturnMessageDto } from '../dto/ReturnMessage.dto';
@Injectable()
export class AvatarApiService extends ConsoleLogger {
constructor(private readonly http: HttpService) {
super('AvatarApiService');
}
async getRealUsername(originalUsername: string) {
try {
const {
data: {
user: { name, username },
},
} = await lastValueFrom(
this.http.get(
`https://ygobbs.com/users/${encodeURIComponent(
originalUsername,
)}.json`,
{
responseType: 'json',
},
),
);
return _.uniq([originalUsername, username, name]);
} catch (e) {
this.error(`User ${originalUsername} not found: ${e.toString()}`);
return [originalUsername];
}
}
async getAvatarURL(usernames: string[]): Promise<string> {
for (const username of usernames) {
try {
const {
data: {
user: { avatar: _avatar },
},
} = await lastValueFrom(
this.http.get(
`https://api.moecube.com/accounts/users/${encodeURIComponent(
username,
)}.json`,
{
responseType: 'json',
},
),
);
const avatar = _avatar as string;
if (
avatar?.match(/^http(s)?:\/\//) &&
!avatar.endsWith('default_avatar.jpg')
) {
return avatar;
}
} catch (e) {
this.error(`Avatar of ${username} not found: ${e.toString()}`);
}
}
return 'https://cdn02.moecube.com:444/accounts/default_avatar.jpg';
}
async getURLFromUsername(username: string) {
const possibleNames = await this.getRealUsername(username);
return this.getAvatarURL(possibleNames);
}
async downloadAvatar(url: string) {
try {
const { data } = await lastValueFrom(
this.http.get<Buffer>(url, {
responseType: 'arraybuffer',
}),
);
return data;
} catch (e) {
this.error(`Failed to download avatar from ${url}: ${e.toString()}`);
throw new BlankReturnMessageDto(404, 'Failed to download avatar');
}
}
}
import { ConsoleLogger } from '@nestjs/common';
import { ClassConstructor } from 'class-transformer';
import {
DeleteResult,
FindConditions,
In,
Repository,
SelectQueryBuilder,
UpdateResult,
} from 'typeorm';
import {
BlankReturnMessageDto,
PaginatedReturnMessageDto,
ReturnMessageDto,
} from '../dto/ReturnMessage.dto';
import { QueryWise } from '../entities/interfaces/QueryWise';
import { camelCase } from 'typeorm/util/StringUtils';
import { DeletionWise } from '../entities/bases/TimeBase.entity';
import { PageSettingsFactory } from '../dto/PageSettings.dto';
export type EntityId<T extends { id: any }> = T['id'];
export class CrudBase<
T extends Record<string, any> & {
id: string | number;
} & QueryWise<T> &
DeletionWise &
PageSettingsFactory
> extends ConsoleLogger {
protected readonly entityName: string;
constructor(
protected entityClass: ClassConstructor<T>,
protected repo: Repository<T>,
protected entityRelations: string[] = [],
// eslint-disable-next-line @typescript-eslint/no-empty-function
protected extraGetQuery: (qb: SelectQueryBuilder<T>) => void = (qb) => {},
) {
super(`${entityClass.name} Service`);
this.entityName = entityClass.name;
}
async batchCreate(ents: T[], beforeCreate?: (repo: Repository<T>) => void) {
const entsWithId = ents.filter((ent) => ent.id != null);
const savedEnt = await this.repo.manager.transaction(async (mdb) => {
const repo = mdb.getRepository(this.entityClass);
if (entsWithId.length) {
const existingEnts = await repo.find({
where: { id: In(entsWithId.map((ent) => ent.id)) },
select: ['id', 'deleteTime'],
withDeleted: true,
});
if (existingEnts.length) {
const existingEntsWithoutDeleteTime = existingEnts.filter(
(ent) => ent.deleteTime == null,
);
if (existingEntsWithoutDeleteTime.length) {
throw new BlankReturnMessageDto(
404,
`${this.entityName} ID ${existingEntsWithoutDeleteTime.join(
',',
)} already exists`,
).toException();
}
await repo.delete(existingEnts.map((ent) => ent.id) as any[]);
}
}
if (beforeCreate) {
await beforeCreate(repo);
}
try {
return await repo.save(ents);
} catch (e) {
this.error(
`Failed to create entity ${JSON.stringify(ents)}: ${e.toString()}`,
);
throw new BlankReturnMessageDto(500, 'internal error').toException();
}
});
return new ReturnMessageDto(201, 'success', savedEnt);
}
async create(ent: T, beforeCreate?: (repo: Repository<T>) => void) {
const savedEnt = await this.repo.manager.transaction(async (mdb) => {
const repo = mdb.getRepository(this.entityClass);
if (ent.id != null) {
const existingEnt = await repo.findOne({
where: { id: ent.id },
select: ['id', 'deleteTime'],
withDeleted: true,
});
if (existingEnt) {
if (existingEnt.deleteTime) {
await repo.delete(existingEnt.id);
} else {
throw new BlankReturnMessageDto(
404,
`${this.entityName} ID ${ent.id} already exists`,
).toException();
}
}
}
if (beforeCreate) {
await beforeCreate(repo);
}
try {
return await repo.save(ent);
} catch (e) {
this.error(
`Failed to create entity ${JSON.stringify(ent)}: ${e.toString()}`,
);
throw new BlankReturnMessageDto(500, 'internal error').toException();
}
});
return new ReturnMessageDto(201, 'success', savedEnt);
}
protected get entityAliasName() {
return camelCase(this.entityName);
}
protected applyRelationToQuery(qb: SelectQueryBuilder<T>, relation: string) {
const relationUnit = relation.split('.');
const base =
relationUnit.length === 1
? this.entityAliasName
: relationUnit.slice(0, relationUnit.length - 1).join('_');
const property = relationUnit[relationUnit.length - 1];
const properyAlias = relationUnit.join('_');
qb.leftJoinAndSelect(`${base}.${property}`, properyAlias);
}
protected applyRelationsToQuery(qb: SelectQueryBuilder<T>) {
for (const relation of this.entityRelations) {
this.applyRelationToQuery(qb, relation);
}
}
protected queryBuilder() {
return this.repo.createQueryBuilder(this.entityAliasName);
}
async findOne(
id: EntityId<T>,
extraQuery: (qb: SelectQueryBuilder<T>) => void = () => {},
) {
const query = this.queryBuilder()
.where(`${this.entityAliasName}.id = :id`, { id })
.take(1);
this.applyRelationsToQuery(query);
this.extraGetQuery(query);
extraQuery(query);
query.take(1);
let ent: T;
try {
ent = await query.getOne();
} catch (e) {
const [sql, params] = query.getQueryAndParameters();
this.error(
`Failed to read entity ID ${id} with SQL ${sql} param ${params.join(
',',
)}: ${e.toString()}`,
);
throw new BlankReturnMessageDto(500, 'internal error').toException();
}
if (!ent) {
throw new BlankReturnMessageDto(
404,
`${this.entityName} ID ${id} not found.`,
).toException();
}
return new ReturnMessageDto(200, 'success', ent);
}
async findAll(
ent?: Partial<T>,
extraQuery: (qb: SelectQueryBuilder<T>) => void = () => {},
) {
const query = this.queryBuilder();
if (ent) {
ent.applyQuery(query, this.entityAliasName);
}
this.applyRelationsToQuery(query);
this.extraGetQuery(query);
extraQuery(query);
try {
const [ents, count] = await query.getManyAndCount();
return new PaginatedReturnMessageDto(
200,
'success',
ents,
count,
ent.getActualPageSettings(),
);
} catch (e) {
const [sql, params] = query.getQueryAndParameters();
this.error(
`Failed to read entity cond ${JSON.stringify(
ent,
)} with SQL ${sql} param ${params.join(',')}: ${e.toString()}`,
);
throw new BlankReturnMessageDto(500, 'internal error').toException();
}
}
async update(
id: EntityId<T>,
entPart: Partial<T>,
cond: FindConditions<T> = {},
) {
let result: UpdateResult;
try {
result = await this.repo.update(
{
id,
...cond,
},
entPart,
);
} catch (e) {
this.error(
`Failed to update entity ID ${id} to ${JSON.stringify(
entPart,
)}: ${e.toString()}`,
);
throw new BlankReturnMessageDto(500, 'internal error').toException();
}
if (!result.affected) {
throw new BlankReturnMessageDto(
404,
`${this.entityName} ID ${id} not found.`,
).toException();
}
return new BlankReturnMessageDto(200, 'success');
}
async remove(
id: EntityId<T>,
hardDelete = false,
cond: FindConditions<T> = {},
) {
let result: UpdateResult | DeleteResult;
const searchCond = {
id,
...cond,
};
try {
result = await (hardDelete
? this.repo.delete(searchCond)
: this.repo.softDelete(searchCond));
} catch (e) {
this.error(`Failed to delete entity ID ${id}: ${e.toString()}`);
throw new BlankReturnMessageDto(500, 'internal error').toException();
}
if (!result.affected) {
throw new BlankReturnMessageDto(
404,
`${this.entityName} ID ${id} not found.`,
).toException();
}
return new BlankReturnMessageDto(204, 'success');
}
}
import { IsInt, IsPositive } from 'class-validator';
import { SelectQueryBuilder } from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { NotWritable } from '../entities/decorators/transform';
export interface PageSettingsWise {
pageCount: number;
recordsPerPage: number;
}
export interface PageSettingsFactory {
getActualPageSettings(): PageSettingsWise;
}
export class PageSettingsDto implements PageSettingsWise, PageSettingsFactory {
@NotWritable()
@IsPositive()
@IsInt()
@ApiProperty({ description: '第 n 页,从 1 开始', required: false })
pageCount: number;
@NotWritable()
@IsPositive()
@IsInt()
@ApiProperty({ description: '每页显示的数量', required: false })
recordsPerPage: number;
getActualPageSettings(): PageSettingsWise {
return {
pageCount: this.getPageCount(),
recordsPerPage: this.getRecordsPerPage(),
};
}
private getPageCount() {
return this.pageCount || 1;
}
private getRecordsPerPage() {
return this.recordsPerPage || 25;
}
private getStartingFrom() {
return (this.getPageCount() - 1) * this.getRecordsPerPage();
}
applyQuery(qb: SelectQueryBuilder<PageSettingsDto>, entityName: string) {
qb.take(this.getRecordsPerPage()).skip(this.getStartingFrom());
}
}
import { ApiProperty } from '@nestjs/swagger';
import { HttpException } from '@nestjs/common';
import { User } from '../entities/User.entity';
import { PageSettingsWise } from './PageSettings.dto';
export interface BlankReturnMessage {
statusCode: number;
message: string;
success: boolean;
}
export interface ReturnMessage<T> extends BlankReturnMessage {
data?: T;
}
export class BlankReturnMessageDto implements BlankReturnMessage {
@ApiProperty({ description: '返回状态' })
statusCode: number;
@ApiProperty({ description: '返回信息' })
message: string;
@ApiProperty({ description: '是否成功' })
success: boolean;
constructor(statusCode: number, message?: string) {
this.statusCode = statusCode;
this.message = message || 'success';
this.success = statusCode < 400;
}
toException() {
return new HttpException(this, this.statusCode);
}
}
export class BlankPaginatedReturnMessageDto
extends BlankReturnMessageDto
implements PageSettingsWise {
@ApiProperty({ description: '总数' })
total: number;
@ApiProperty({ description: '总页数' })
totalPages: number;
@ApiProperty({ description: '当前页' })
pageCount: number;
@ApiProperty({ description: '每页数量' })
recordsPerPage: number;
constructor(
statusCode: number,
message: string,
total: number,
pageSettings: PageSettingsWise,
) {
super(statusCode, message);
this.total = total;
this.pageCount = pageSettings.pageCount;
this.recordsPerPage = pageSettings.recordsPerPage;
this.totalPages = Math.ceil(total / pageSettings.recordsPerPage);
}
}
export class ReturnMessageDto<T>
extends BlankReturnMessageDto
implements ReturnMessage<T> {
@ApiProperty({ description: '返回内容' })
data?: T;
constructor(statusCode: number, message?: string, data?: T) {
super(statusCode, message);
this.data = data;
}
}
export class PaginatedReturnMessageDto<T>
extends BlankPaginatedReturnMessageDto
implements PageSettingsWise {
@ApiProperty({ description: '返回内容' })
data: T[];
constructor(
statusCode: number,
message: string,
data: T[],
total: number,
pageSettings: PageSettingsWise,
) {
super(statusCode, message, total, pageSettings);
this.data = data;
}
}
export class StringReturnMessageDto
extends BlankReturnMessageDto
implements ReturnMessage<string> {
@ApiProperty({ description: '返回内容' })
data?: string;
}
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity()
export class Avatar {
@PrimaryColumn('varchar', { length: 255 })
url: string;
@PrimaryColumn('smallint', { unsigned: true })
size: number;
@Column('text')
content: string;
toBuffer() {
return Buffer.from(this.content, 'base64');
}
fromBuffer(buffer: Buffer) {
this.content = buffer.toString('base64');
}
}
import { IdNameDescBase } from './IdNameDescBase.entity';
import { StringColumn } from '../decorators/base';
import { SelectQueryBuilder } from 'typeorm';
import { applyQueryPropertyLike } from '../utility/query';
export class AvatarBase extends IdNameDescBase {
@StringColumn(128, '图标地址', undefined, false)
avatarUrl: string;
override applyQuery(qb: SelectQueryBuilder<AvatarBase>, entityName: string) {
super.applyQuery(qb, entityName);
applyQueryPropertyLike(this, qb, entityName, 'avatarUrl');
}
}
import { Column, Generated, SelectQueryBuilder } from 'typeorm';
import { IdWise } from '../interfaces/wises';
import { ApiProperty } from '@nestjs/swagger';
import { applyQueryProperty } from '../utility/query';
import { NotWritable } from '../decorators/transform';
import { IsInt, IsPositive } from 'class-validator';
import { BigintTransformer } from '../utility/bigint-transform';
import { TimeBase } from './TimeBase.entity';
export class IdBase extends TimeBase implements IdWise {
@Generated('increment')
@Column('bigint', {
primary: true,
unsigned: true,
transformer: new BigintTransformer(),
})
@ApiProperty({ description: '编号', required: false })
@NotWritable()
@IsInt()
@IsPositive()
id: number;
override applyQuery(qb: SelectQueryBuilder<IdBase>, entityName: string) {
super.applyQuery(qb, entityName);
qb.orderBy(`${entityName}.id`, 'DESC');
applyQueryProperty(this, qb, entityName, 'id');
}
}
import { IdBase } from './IdBase.entity';
import { IdNameWise } from '../interfaces/wises';
import { EntityName } from '../decorators/extended';
import { SelectQueryBuilder } from 'typeorm';
import { applyQueryPropertyLike } from '../utility/query';
export class IdNameBase extends IdBase implements IdNameWise {
@EntityName()
name: string;
override applyQuery(qb: SelectQueryBuilder<IdNameBase>, entityName: string) {
super.applyQuery(qb, entityName);
applyQueryPropertyLike(this, qb, entityName, 'name');
}
}
import { IdNameBase } from './IdNameBase.entity';
import { IdNameDescWise } from '../interfaces/wises';
import { EntityDescription } from '../decorators/extended';
import { SelectQueryBuilder } from 'typeorm';
import { applyQueryPropertySearch } from '../utility/query';
export class IdNameDescBase extends IdNameBase implements IdNameDescWise {
@EntityDescription()
desc: string;
override applyQuery(
qb: SelectQueryBuilder<IdNameDescBase>,
entityName: string,
) {
super.applyQuery(qb, entityName);
applyQueryPropertySearch(this, qb, entityName, 'desc');
}
}
import { StringColumn } from '../decorators/base';
import { SelectQueryBuilder } from 'typeorm';
import { applyQueryProperty } from '../utility/query';
import { ManualNameDescBase } from './ManualNameDescBase.entity';
export class AvatarBase extends ManualNameDescBase {
@StringColumn(128, '图标地址', undefined, false)
avatarUrl: string;
override applyQuery(qb: SelectQueryBuilder<AvatarBase>, entityName: string) {
super.applyQuery(qb, entityName);
applyQueryProperty(this, qb, entityName, 'avatarUrl');
}
}
import { PrimaryColumn, SelectQueryBuilder } from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { StringIdWise } from '../interfaces/wises';
import { applyQueryProperty } from '../utility/query';
import { NotChangeable } from '../decorators/transform';
import { IsNotEmpty, IsString } from 'class-validator';
import { TimeBase } from './TimeBase.entity';
export class ManualIdBase extends TimeBase implements StringIdWise {
@PrimaryColumn('varchar', { length: 32 })
@ApiProperty({ description: '编号' })
@NotChangeable()
@IsString()
@IsNotEmpty()
id: string;
override applyQuery(
qb: SelectQueryBuilder<ManualIdBase>,
entityName: string,
) {
super.applyQuery(qb, entityName);
qb.orderBy(`${entityName}.id`, 'ASC');
applyQueryProperty(this, qb, entityName, 'id');
}
}
import { ManualIdBase } from './ManualIdBase.entity';
import { StringIdNameWise } from '../interfaces/wises';
import { EntityName } from '../decorators/extended';
import { SelectQueryBuilder } from 'typeorm';
import { applyQueryPropertyLike } from '../utility/query';
export class ManualNameBase extends ManualIdBase implements StringIdNameWise {
@EntityName()
name: string;
override applyQuery(
qb: SelectQueryBuilder<ManualNameBase>,
entityName: string,
) {
super.applyQuery(qb, entityName);
applyQueryPropertyLike(this, qb, entityName, 'name');
}
}
import { ManualNameBase } from './ManualNameBase.entity';
import { StringIdNameDescWise } from '../interfaces/wises';
import { EntityDescription } from '../decorators/extended';
import { SelectQueryBuilder } from 'typeorm';
import { applyQueryPropertySearch } from '../utility/query';
export class ManualNameDescBase
extends ManualNameBase
implements StringIdNameDescWise
{
@EntityDescription()
desc: string;
override applyQuery(
qb: SelectQueryBuilder<ManualNameDescBase>,
entityName: string,
) {
super.applyQuery(qb, entityName);
applyQueryPropertySearch(this, qb, entityName, 'desc');
}
}
import { CreateDateColumn, DeleteDateColumn, UpdateDateColumn } from 'typeorm';
import { PageSettingsDto } from '../../dto/PageSettings.dto';
import { NotColumn } from '../decorators/base';
export interface DeletionWise {
deleteTime?: Date;
}
export class TimeBase extends PageSettingsDto implements DeletionWise {
@CreateDateColumn({ select: false })
@NotColumn()
createTime: Date;
@UpdateDateColumn({ select: false })
@NotColumn()
updateTime: Date;
@DeleteDateColumn({ select: false })
@NotColumn()
deleteTime: Date;
toObject() {
return JSON.parse(JSON.stringify(this));
}
}
export const TimeBaseFields: (keyof TimeBase)[] = [
'createTime',
'updateTime',
'deleteTime',
];
import { Column, Index } from 'typeorm';
import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger';
import { ColumnWithLengthOptions } from 'typeorm/decorator/options/ColumnWithLengthOptions';
import { ColumnCommonOptions } from 'typeorm/decorator/options/ColumnCommonOptions';
import { ColumnEnumOptions } from 'typeorm/decorator/options/ColumnEnumOptions';
import {
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Min,
} from 'class-validator';
import { ColumnWithWidthOptions } from 'typeorm/decorator/options/ColumnWithWidthOptions';
import { BigintTransformer } from '../utility/bigint-transform';
import { Exclude } from 'class-transformer';
export function MergePropertyDecorators(
decs: PropertyDecorator[],
): PropertyDecorator {
return (obj, key) => {
for (const dec of decs) {
dec(obj, key);
}
};
}
export const OptionalValidate = (...conitions: PropertyDecorator[]) =>
MergePropertyDecorators([IsOptional(), ...conitions]);
export const StringColumn = (
length = 32,
description = 'unknown',
defaultValue?: string,
required = false,
columnExtras: ColumnCommonOptions & ColumnWithLengthOptions = {},
propertyExtras: ApiPropertyOptions = {},
) =>
MergePropertyDecorators([
Column('varchar', {
length,
default: defaultValue,
nullable: !required && defaultValue == null,
...columnExtras,
}),
ApiProperty({
type: String,
description,
default: defaultValue,
required: required && defaultValue == null,
...propertyExtras,
}),
...(required ? [] : [IsOptional()]),
IsString(),
IsNotEmpty(),
]);
export const IntColumn = (
type: 'int' | 'smallint' | 'bigint' | 'tinyint' = 'int',
unsigned = false,
description = 'unknown',
defaultValue?: number,
required = false,
columnExtras: ColumnCommonOptions & ColumnWithWidthOptions = {},
propertyExtras: ApiPropertyOptions = {},
) =>
MergePropertyDecorators([
Column(type, {
default: defaultValue,
nullable: !required && defaultValue == null,
unsigned,
...(type === 'bigint' ? { transformer: new BigintTransformer() } : {}),
...columnExtras,
}),
ApiProperty({
type: Number,
description,
default: defaultValue,
required: required && defaultValue == null,
...propertyExtras,
}),
...(required ? [] : [IsOptional()]),
IsInt(),
...(unsigned ? [Min(0)] : []),
]);
export const EnumColumn = <T>(
targetEnum: Record<string, T>,
description = 'unknown',
defaultValue?: T,
required = false,
columnExtras: ColumnCommonOptions & ColumnEnumOptions = {},
swaggerExtras: ApiPropertyOptions = {},
) =>
MergePropertyDecorators([
Index(),
Column('enum', {
enum: targetEnum,
default: defaultValue,
nullable: !required && !defaultValue,
...columnExtras,
}),
ApiProperty({
description,
enum: targetEnum,
default: defaultValue,
required,
...swaggerExtras,
}),
...(required ? [] : [IsOptional()]),
IsEnum(targetEnum),
]);
export const NotColumn = (
description?: string,
swaggerExtras: ApiPropertyOptions = {},
): PropertyDecorator =>
MergePropertyDecorators([
Exclude(),
ApiProperty({ description, required: false, ...swaggerExtras }),
]);
import { MergePropertyDecorators, StringColumn } from './base';
import { Index } from 'typeorm';
export const EntityName = (length = 32, description = '名称') =>
MergePropertyDecorators([
Index(),
StringColumn(length, description, undefined, true),
]);
export const EntityDescription = (length = 5000, description = '描述') =>
StringColumn(length, description, '暂无介绍', false);
import { Column } from 'typeorm';
import { MergePropertyDecorators } from './base';
import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger';
import { IsInt, IsNotEmpty, IsOptional, IsPositive } from 'class-validator';
import { BigintTransformer } from '../utility/bigint-transform';
import { ColumnCommonOptions } from 'typeorm/decorator/options/ColumnCommonOptions';
import { ColumnWithLengthOptions } from 'typeorm/decorator/options/ColumnWithLengthOptions';
import { ColumnWithWidthOptions } from 'typeorm/decorator/options/ColumnWithWidthOptions';
export const RelationColumn = (
description = '对应编号',
notNull = false,
columnExtras: ColumnCommonOptions & ColumnWithWidthOptions = {},
propertyExtras: ApiPropertyOptions = {},
) =>
MergePropertyDecorators([
Column('bigint', {
nullable: !notNull,
unsigned: true,
transformer: new BigintTransformer(),
...columnExtras,
}),
ApiProperty({ type: Number, description, ...propertyExtras }),
...(notNull ? [] : [IsOptional()]),
IsInt(),
IsPositive(),
]);
export const StringRelationColumn = (
description = '对应编号',
notNull = false,
columnExtras: ColumnCommonOptions & ColumnWithLengthOptions = {},
propertyExtras: ApiPropertyOptions = {},
) =>
MergePropertyDecorators([
Column('varchar', { length: 32, nullable: !notNull, ...columnExtras }),
ApiProperty({ type: String, description, ...propertyExtras }),
...(notNull ? [] : [IsOptional()]),
IsNotEmpty(),
]);
import { Expose } from 'class-transformer';
import { MergePropertyDecorators } from './base';
import { IsOptional } from 'class-validator';
export const NotWritable = () =>
MergePropertyDecorators([Expose({ groups: ['r'] }), IsOptional()]);
export const NotChangeable = () => Expose({ groups: ['r', 'c'] });
import { SelectQueryBuilder } from 'typeorm';
export interface QueryWise<T> {
applyQuery(qb: SelectQueryBuilder<T>, entityName: string): void;
}
export interface IdWise {
id: number;
}
export interface StringIdWise {
id: string;
}
export interface NameWise {
name: string;
}
export interface DescWise {
desc: string;
}
export interface NameDescWise extends NameWise, DescWise {}
export interface IdNameWise extends IdWise, NameWise {}
export interface StringIdNameWise extends StringIdWise, NameWise {}
export interface IdNameDescWise extends IdWise, NameDescWise {}
export interface StringIdNameDescWise extends StringIdNameWise, DescWise {}
import { ValueTransformer } from 'typeorm';
export class BigintTransformer implements ValueTransformer {
from(dbValue) {
if (dbValue == null) {
return dbValue;
}
return parseInt(dbValue);
}
to(entValue): any {
return entValue;
}
}
import { TimeBase } from '../bases/TimeBase.entity';
import { SelectQueryBuilder } from 'typeorm';
export function applyQueryProperty<T extends TimeBase>(
obj: T,
qb: SelectQueryBuilder<T>,
entityName: string,
...fields: (keyof T)[]
) {
for (const field of fields) {
if (obj[field] == null) {
continue;
}
qb.andWhere(`${entityName}.${field} = :${field}`, { [field]: obj[field] });
}
}
export function applyQueryPropertyLike<T extends TimeBase>(
obj: T,
qb: SelectQueryBuilder<T>,
entityName: string,
...fields: (keyof T)[]
) {
for (const field of fields) {
if (obj[field] == null) {
continue;
}
qb.andWhere(`${entityName}.${field} like (:${field} || '%')`, {
[field]: obj[field],
});
}
}
export function applyQueryPropertySearch<T extends TimeBase>(
obj: T,
qb: SelectQueryBuilder<T>,
entityName: string,
...fields: (keyof T)[]
) {
for (const field of fields) {
if (obj[field] == null) {
continue;
}
qb.andWhere(`${entityName}.${field} like ('%' || :${field} || '%')`, {
[field]: obj[field],
});
}
}
import { Test, TestingModule } from '@nestjs/testing';
import { ImageService } from './image.service';
describe('ImageService', () => {
let service: ImageService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ImageService],
}).compile();
service = module.get<ImageService>(ImageService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
import { ConsoleLogger, Injectable } from '@nestjs/common';
import magick from '@xzeldon/imagemagick-native';
import { BlankReturnMessageDto } from '../dto/ReturnMessage.dto';
@Injectable()
export class ImageService extends ConsoleLogger {
constructor() {
super('ImageService');
}
async resize(buffer: Buffer, size: number) {
try {
return await magick.promises.convert({
format: 'PNG',
srcData: buffer,
width: size,
height: size,
});
} catch (e) {
console.error(`Error resizing image to ${size}: ${e.toString()}`);
throw new BlankReturnMessageDto(500, 'Error resizing image');
}
}
}
import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableCors();
app.set('trust proxy', ['172.16.0.0/12', 'loopback']);
const documentConfig = new DocumentBuilder()
.setTitle('app')
.setDescription('The app')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, documentConfig);
SwaggerModule.setup('docs', app, document);
await app.listen(3000);
}
bootstrap();
import { ValidationPipe } from '@nestjs/common';
import { ClassConstructor, plainToClass } from 'class-transformer';
export const CreatePipe = new ValidationPipe({
transform: true,
transformOptions: { groups: ['c'], enableImplicitConversion: true },
});
export const GetPipe = new ValidationPipe({
transform: true,
transformOptions: { groups: ['r'], enableImplicitConversion: true },
skipMissingProperties: true,
skipNullProperties: true,
skipUndefinedProperties: true,
});
export class ClassGetPipe<T> extends ValidationPipe {
constructor(private readonly classConstructor: ClassConstructor<T>) {
super({
transform: false,
transformOptions: {
groups: ['r'],
enableImplicitConversion: true,
},
skipMissingProperties: true,
skipNullProperties: true,
skipUndefinedProperties: true,
});
}
override async transform(value: any, metadata: any) {
const obj = await super.transform(value, metadata);
return plainToClass(this.classConstructor, obj, {
groups: ['r'],
enableImplicitConversion: false,
});
}
}
export const UpdatePipe = new ValidationPipe({
transform: true,
transformOptions: { groups: ['u'], enableImplicitConversion: true },
skipMissingProperties: true,
skipNullProperties: true,
skipUndefinedProperties: true,
});
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
/* it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
}); */
});
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "es2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"esModuleInterop": true
},
"compileOnSave": true,
"allowJs": true
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment