Commit b38229d0 authored by nanahira's avatar nanahira

finish

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',
tsconfigRootDir : __dirname,
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:lts-bullseye-slim as base
LABEL Author="Nanahira <nanahira@momobako.com>"
RUN apt update && apt -y install python3 build-essential libpq-dev && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/*
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
COPY ./config.example.yaml ./config.yaml
ENV NODE_PG_FORCE_NATIVE=true
EXPOSE 3000
CMD [ "npm", "run", "start:prod" ]
This diff is collapsed.
# wanyuanwall-api
API for https://twitter.com/Hamimelon7/
## Installation
```bash
$ npm install
```
## Config
Make a copy of `config.example.yaml` to `config.yaml`.
## Running the app
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## License
AGPLv3
host: '::'
port: 3000
DB_HOST: localhost
DB_USER: nanahira
DB_PASS: password
DB_NAME: wall
INITIAL_TIME: '2021-12-01 00:00:00'
FETCHER_URL: http://wenyuanwall-fetcher:3000
CRON: '0 0 1 * * *'
# http:
#!/bin/bash
npm install --save \
class-validator \
class-transformer \
@nestjs/swagger \
@nestjs/config \
yaml
npm install --save-dev \
@types/express
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/swagger"]
}
}
This diff is collapsed.
{
"name": "wenyuanwall-api",
"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.1.0",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.0.0",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/swagger": "^6.0.3",
"@nestjs/typeorm": "^9.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"node-schedule": "^2.1.0",
"pg": "^8.7.3",
"pg-native": "^3.0.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"typeorm": "^0.3.7",
"yaml": "^2.1.1"
},
"devDependencies": {
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@types/express": "^4.17.13",
"@types/jest": "28.1.4",
"@types/lodash": "^4.14.182",
"@types/node": "^16.0.0",
"@types/node-schedule": "^2.1.0",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "28.1.2",
"prettier": "^2.3.2",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "28.0.5",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
"tsconfig-paths": "4.0.0",
"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 { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { loadConfig } from './utility/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Blacklist } from './entities/blacklist.entity';
import { BlacklistAccount } from './entities/blacklist-account.entity';
import { BlacklistAccountService } from './blacklist-account/blacklist-account.service';
import { BlacklistAccountController } from './blacklist-account/blacklist-account.controller';
import { BlacklistService } from './blacklist/blacklist.service';
import { FetchService } from './fetch/fetch.service';
import { HttpModule } from '@nestjs/axios';
@Module({
imports: [
ConfigModule.forRoot({
load: [loadConfig],
isGlobal: true,
ignoreEnvVars: true,
ignoreEnvFile: true,
}),
HttpModule.registerAsync({
inject: [ConfigService],
useFactory: async (config: ConfigService) => config.get('http') || {},
}),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: async (config: ConfigService) => ({
type: 'postgres',
entities: [Blacklist, BlacklistAccount],
autoLoadEntities: true,
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,
}),
}),
],
providers: [BlacklistAccountService, BlacklistService, FetchService],
controllers: [BlacklistAccountController],
})
export class AppModule {}
import { Test, TestingModule } from '@nestjs/testing';
import { BlacklistAccountController } from './blacklist-account.controller';
describe('BlacklistAccountController', () => {
let controller: BlacklistAccountController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [BlacklistAccountController],
}).compile();
controller = module.get<BlacklistAccountController>(BlacklistAccountController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
import { Controller } from '@nestjs/common';
import { CrudFactory } from '../decorators/crud.decorators';
import { BlacklistAccount } from '../entities/blacklist-account.entity';
import { BlacklistAccountService } from './blacklist-account.service';
const dec = new CrudFactory(BlacklistAccount, ['blacklist']);
class UpdateDto extends dec.updateDto {}
@Controller('blacklist')
export class BlacklistAccountController {
constructor(
private readonly blacklistAccountService: BlacklistAccountService,
) {}
@dec.findAll()
findAll(@dec.findAllParam() param: UpdateDto) {
return this.blacklistAccountService.findAll(param);
}
}
import { Test, TestingModule } from '@nestjs/testing';
import { BlacklistAccountService } from './blacklist-account.service';
describe('BlacklistAccountService', () => {
let service: BlacklistAccountService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [BlacklistAccountService],
}).compile();
service = module.get<BlacklistAccountService>(BlacklistAccountService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
import { Injectable } from '@nestjs/common';
import { CrudBase, Inner } from '../crud-base/crud-base';
import { BlacklistAccount } from '../entities/blacklist-account.entity';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@Injectable()
export class BlacklistAccountService extends CrudBase<BlacklistAccount> {
constructor(@InjectDataSource() db: DataSource) {
super(BlacklistAccount, db.getRepository(BlacklistAccount), [
Inner('blacklist'),
]);
}
}
import { Test, TestingModule } from '@nestjs/testing';
import { BlacklistService } from './blacklist.service';
describe('BlacklistService', () => {
let service: BlacklistService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [BlacklistService],
}).compile();
service = module.get<BlacklistService>(BlacklistService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
import { Injectable } from '@nestjs/common';
import { CrudBase, Inner } from '../crud-base/crud-base';
import { Blacklist, HamiData } from '../entities/blacklist.entity';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import moment from 'moment';
import _ from 'lodash';
@Injectable()
export class BlacklistService extends CrudBase<Blacklist> {
constructor(
@InjectDataSource() db: DataSource,
private config: ConfigService,
) {
super(Blacklist, db.getRepository(Blacklist), [Inner('blacklistAccount')]);
}
async getSinceTime() {
const [lastEntry] = await this.repo.find({
take: 1,
select: { time: true },
order: { time: 'desc' },
});
if (!lastEntry) {
return this.config.get<string>('INITIAL_TIME') || '2022-01-01 00:00:00';
}
return moment(lastEntry.time)
.add(1, 'second')
.format('YYYY-MM-DD HH:mm:ss');
}
async saveEntries(entries: HamiData[]) {
this.log(`Saving ${entries.length} entries...`);
const chunks = _.chunk(entries.reverse(), 4000);
for (const chunk of chunks) {
const blacklists = chunk.map((d) => new Blacklist().fromData(d));
this.log(`Saving current chunk of ${blacklists.length} entries...`);
try {
const result = await this.batchCreate(blacklists, undefined, true);
this.log(
`Saved ${result.results.length} entries, and skipped ${result.skipped.length} duplicated entries.`,
);
} catch (e) {
this.log(`Failed to save ${chunk.length} entries: ${e.message}`);
}
}
this.log(`Finished saving ${entries.length} entries.`);
}
}
This diff is collapsed.
import {
ApiBody,
ApiCreatedResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
OmitType,
PartialType,
} from '@nestjs/swagger';
import { Body, Delete, Get, Patch, Post, Query, Type } from '@nestjs/common';
import {
BlankReturnMessageDto,
PaginatedReturnMessageDto,
ReturnMessageDto,
} from '../dto/ReturnMessage.dto';
import { TimeBase, TimeBaseFields } from '../entities/bases/TimeBase.entity';
import { ClassGetPipe, CreatePipe, UpdatePipe } from '../utility/pipes';
export function MergeMethodDecorators(
decorators: MethodDecorator[],
): MethodDecorator {
return (target: any, key: string, descriptor: PropertyDescriptor) => {
decorators.forEach((decorator) => {
decorator(target, key, descriptor);
});
};
}
export class CrudFactory<T extends TimeBase> {
readonly createDto: Type<Omit<T, keyof T>>;
readonly updateDto: Type<Partial<Omit<T, keyof T>>>;
readonly entityReturnMessageDto = ReturnMessageDto(this.entityClass);
readonly entityArrayReturnMessageDto = PaginatedReturnMessageDto(
this.entityClass,
);
constructor(
public readonly entityClass: Type<T>,
fieldsToOmit: (keyof T)[] = [],
// eslint-disable-next-line @typescript-eslint/ban-types
public readonly idType: Function = Number,
) {
this.createDto = OmitType(this.entityClass, [
...TimeBaseFields,
...fieldsToOmit,
]);
this.updateDto = PartialType(this.createDto);
}
create(): MethodDecorator {
return MergeMethodDecorators([
Post(),
ApiOperation({ summary: `Create a new ${this.entityClass.name}` }),
ApiBody({ type: this.createDto }),
ApiCreatedResponse({ type: this.entityReturnMessageDto }),
]);
}
createParam() {
return Body(CreatePipe);
}
findOne(): MethodDecorator {
return MergeMethodDecorators([
Get(':id'),
ApiOperation({ summary: `Find a ${this.entityClass.name} by id` }),
ApiParam({ name: 'id', type: this.idType, required: true }),
ApiOkResponse({ type: this.entityReturnMessageDto }),
]);
}
findAll(): MethodDecorator {
return MergeMethodDecorators([
Get(),
ApiOperation({ summary: `Find all ${this.entityClass.name}` }),
ApiOkResponse({ type: this.entityArrayReturnMessageDto }),
]);
}
findAllParam() {
return Query(new ClassGetPipe(this.entityClass));
}
update(): MethodDecorator {
return MergeMethodDecorators([
Patch(':id'),
ApiOperation({ summary: `Update a ${this.entityClass.name} by id` }),
ApiParam({ name: 'id', type: this.idType, required: true }),
ApiBody({ type: this.updateDto }),
ApiOkResponse({ type: BlankReturnMessageDto }),
]);
}
updateParam() {
return Body(UpdatePipe);
}
delete(): MethodDecorator {
return MergeMethodDecorators([
Delete(':id'),
ApiOperation({ summary: `Delete a ${this.entityClass.name} by id` }),
ApiParam({ name: 'id', type: this.idType, required: true }),
ApiNoContentResponse({ type: BlankReturnMessageDto }),
]);
}
}
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: 'The nth page, starting with 1.',
required: false,
})
pageCount: number;
@NotWritable()
@IsPositive()
@IsInt()
@ApiProperty({ description: 'Records per page.', 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 { 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: 'Return code' })
statusCode: number;
@ApiProperty({ description: 'Return message' })
message: string;
@ApiProperty({ description: 'Whether success.' })
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 record count.' })
total: number;
@ApiProperty({ description: 'Total page count.' })
totalPages: number;
@ApiProperty({ description: 'Current page.' })
pageCount: number;
@ApiProperty({ description: 'Records per page.' })
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);
}
}
type AnyClass = new (...args: any[]) => any;
type ClassOrArray = AnyClass | [AnyClass];
type TypeFromClass<T> = T extends new (...args: any[]) => infer U ? U : never;
export type ParseType<T extends ClassOrArray> = T extends [infer U]
? TypeFromClass<U>[]
: TypeFromClass<T>;
function getClass(o: ClassOrArray) {
return o instanceof Array ? o[0] : o;
}
export function ReturnMessageDto<T extends ClassOrArray>(type: T) {
const cl = class SpecificReturnMessage extends BlankReturnMessageDto {
data?: ParseType<T>;
constructor(statusCode: number, message?: string, data?: ParseType<T>) {
super(statusCode, message);
this.data = data;
}
};
ApiProperty({ description: 'Return data.', type })(cl.prototype, 'data');
Object.defineProperty(cl, 'name', {
value: `${getClass(type).name}ReturnMessageDto`,
});
return cl;
}
export function PaginatedReturnMessageDto<T extends AnyClass>(type: T) {
const cl = class SpecificPaginatedReturnMessageDto
extends BlankPaginatedReturnMessageDto
implements PageSettingsWise
{
data?: TypeFromClass<T>[];
constructor(
statusCode: number,
message: string,
data: TypeFromClass<T>[],
total: number,
pageSettings: PageSettingsWise,
) {
super(statusCode, message, total, pageSettings);
this.data = data;
}
};
ApiProperty({ description: 'Return data.', type: [type] })(
cl.prototype,
'data',
);
Object.defineProperty(cl, 'name', {
value: `${getClass(type).name}PaginatedReturnMessageDto`,
});
return cl;
}
export class StringReturnMessageDto
extends BlankReturnMessageDto
implements ReturnMessage<string>
{
@ApiProperty({ description: 'Return data.' })
data?: string;
constructor(statusCode: number, message?: string, data?: string) {
super(statusCode, message);
this.data = data;
}
}
import { ApiProperty } from '@nestjs/swagger';
export class ImportEntryBaseDto {
@ApiProperty({ description: 'Import result' })
result: string;
}
export interface ImportEntry<T> {
entry: T;
result: string;
}
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 interface ImportWise {
isValidInCreation(): string | undefined;
prepareForSaving(): Promise<void>;
afterSaving(): void;
}
export class TimeBase
extends PageSettingsDto
implements DeletionWise, ImportWise
{
@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));
}
isValidInCreation(): string | undefined {
return;
}
async prepareForSaving(): Promise<void> {}
afterSaving() {}
}
export const TimeBaseFields: (keyof TimeBase)[] = [
'createTime',
'updateTime',
'deleteTime',
];
import { Entity, ManyToOne, SelectQueryBuilder, Unique } from 'typeorm';
import { IdBase } from './bases/IdBase.entity';
import { NotColumn, StringColumn } from './decorators/base';
import { StringRelationColumn } from './decorators/relation';
import { Blacklist } from './blacklist.entity';
import { applyQueryProperty } from './utility/query';
@Unique(['account', 'blacklist'])
@Entity()
export class BlacklistAccount extends IdBase {
@StringColumn(11, 'QQ account number.', undefined, true)
account: string;
@StringRelationColumn('Blacklist ID.', true)
blacklistId: string;
@NotColumn()
@ManyToOne(() => Blacklist, (blacklist) => blacklist.accounts, {
onDelete: 'CASCADE',
})
blacklist: Blacklist;
override applyQuery(
qb: SelectQueryBuilder<BlacklistAccount>,
entityName: string,
) {
super.applyQuery(qb, entityName);
applyQueryProperty(this, qb, entityName, 'account', 'blacklistId');
}
}
import { Entity, Index, OneToMany } from 'typeorm';
import { ManualIdBase } from './bases/ManualIdBase.entity';
import { DateColumn, NotColumn, StringColumn } from './decorators/base';
import { BlacklistAccount } from './blacklist-account.entity';
interface TwintData {
cashtags: string[];
conversation_id: string;
datestamp: string;
datetime: string;
geo: string;
hashtags: string[];
id: number;
id_str: string;
lang: string;
likes_count: number;
link: string;
mentions: string[];
name: string;
near: string;
photos: string[];
place: string;
quote_url: string;
replies_count: number;
reply_to: string[];
retweet: boolean;
retweet_date: string;
retweet_id: string;
retweets_count: number;
source: string;
thumbnail: string;
timestamp: string;
timezone: string;
trans_dest: string;
trans_src: string;
translate: string;
tweet: string;
urls: string[];
user_id: number;
user_id_str: string;
user_rt: string;
user_rt_id: string;
username: string;
video: number;
}
export interface HamiData extends TwintData {
accounts: string[];
}
@Entity()
export class Blacklist extends ManualIdBase {
@StringColumn(100, 'Twitter link.', undefined, true)
link: string;
@Index()
@DateColumn('Tweet time', true)
time: Date;
@StringColumn(500, 'Tweet content.', undefined, true)
content: string;
@NotColumn()
@OneToMany(
(type) => BlacklistAccount,
(blacklistAccount) => blacklistAccount.blacklist,
{ cascade: true },
)
accounts: BlacklistAccount[];
fromData(data: HamiData) {
this.link = data.link;
this.id = data.id_str;
this.time = new Date(data.datetime.slice(0, -4));
this.content = data.tweet.replace(/\s*https:\/\/t\.co\/.*/, '');
this.accounts = data.accounts.map((account) => {
const ent = new BlacklistAccount();
ent.account = account;
return ent;
});
return this;
}
}
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 {
IsDate,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
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,
comment: description,
...columnExtras,
}),
ApiProperty({
type: String,
description,
default: defaultValue,
required: required && defaultValue == null,
maxLength: length,
...propertyExtras,
}),
...(required ? [] : [IsOptional()]),
IsString(),
IsNotEmpty(),
MaxLength(length),
]);
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,
comment: description,
...(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,
comment: description,
...columnExtras,
}),
ApiProperty({
description,
enum: targetEnum,
default: defaultValue,
required,
...swaggerExtras,
}),
...(required ? [] : [IsOptional()]),
IsEnum(targetEnum),
]);
export const DateColumn = <T>(
description = 'unknown',
required = false,
columnExtras: ColumnCommonOptions & ColumnEnumOptions = {},
swaggerExtras: ApiPropertyOptions = {},
) =>
MergePropertyDecorators([
IsDate(),
...(required ? [] : [IsOptional()]),
Column('timestamp', {
nullable: !required,
comment: description,
...columnExtras,
}),
ApiProperty({
description,
type: Date,
required,
...swaggerExtras,
}),
]);
export const NotColumn = (
description?: string,
swaggerExtras: ApiPropertyOptions = {},
): PropertyDecorator =>
MergePropertyDecorators([
Exclude(),
ApiProperty({
description,
required: false,
readOnly: true,
...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(),
comment: description,
...columnExtras,
}),
ApiProperty({
type: Number,
description,
required: notNull,
...propertyExtras,
}),
...(notNull ? [] : [IsOptional()]),
IsInt(),
IsPositive(),
]);
export const StringRelationColumn = (
description = '对应编号',
notNull = false,
columnExtras: ColumnCommonOptions & ColumnWithLengthOptions = {},
propertyExtras: ApiPropertyOptions = {},
) =>
MergePropertyDecorators([
Column('varchar', {
length: 32,
nullable: !notNull,
comment: description,
...columnExtras,
}),
ApiProperty({
type: String,
required: notNull,
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 & string)[]
) {
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 & string)[]
) {
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 & string)[]
) {
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 { FetchService } from './fetch.service';
describe('FetchService', () => {
let service: FetchService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [FetchService],
}).compile();
service = module.get<FetchService>(FetchService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
import { ConsoleLogger, Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { BlacklistService } from '../blacklist/blacklist.service';
import { lastValueFrom } from 'rxjs';
import { HamiData } from '../entities/blacklist.entity';
import { scheduleJob } from 'node-schedule';
@Injectable()
export class FetchService extends ConsoleLogger implements OnModuleInit {
private readonly fetcherUrl =
this.config.get<string>('FETCHER_URL') || 'http://wenyuanwall-fetcher:3000';
private readonly cron = this.config.get<string>('CRON');
constructor(
private http: HttpService,
private config: ConfigService,
private blacklist: BlacklistService,
) {
super('FetchService');
}
onModuleInit() {
this.prepare().then();
}
async prepare() {
if (!this.cron) {
return;
}
await this.fetch();
scheduleJob(this.cron, () => this.fetch());
}
async fetch() {
const since = await this.blacklist.getSinceTime();
this.log(`Fetching entries since ${since}...`);
try {
const {
data: { data },
} = await lastValueFrom(
this.http.get<{ data: HamiData[] }>(
`${this.fetcherUrl}/api/blacklist`,
{ responseType: 'json', timeout: 7200000, params: { since } },
),
);
this.log(`Fetched ${data.length} entries.`);
await this.blacklist.saveEntries(data);
this.log(`Done.`);
} catch (e) {
this.error(`Failed to fetch entries: ${e.message}`);
}
}
}
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.setGlobalPrefix('api');
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);
const config = app.get(ConfigService);
await app.listen(
config.get<number>('port') || 3000,
config.get<string>('host') || '::',
);
}
bootstrap();
import yaml from 'yaml';
import * as fs from 'fs';
export interface Config {
host: string;
port: number;
}
const defaultConfig: Config = {
host: '::',
port: 3000,
};
export async function loadConfig(): Promise<Config> {
let readConfig: Partial<Config> = {};
try {
const configText = await fs.promises.readFile('./config.yaml', 'utf-8');
readConfig = yaml.parse(configText);
} catch (e) {
console.error(`Failed to read config: ${e.toString()}`);
}
return {
...defaultConfig,
...readConfig,
...process.env,
};
}
import { ValidationPipe } from '@nestjs/common';
import { ClassConstructor, plainToInstance } 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 plainToInstance(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