From c5a8238ef023307af8b9c677d2283613fb339786 Mon Sep 17 00:00:00 2001 From: South Drifted Date: Tue, 15 Oct 2024 18:08:18 +0800 Subject: [PATCH] [add] models & controllers of Base, Activity Log (#28) --- package.json | 5 +- pnpm-lock.yaml | 85 +++++++-------------- src/controller/ActivityLog.ts | 114 ++++++++++++++++++++++++++++ src/controller/Base.ts | 29 +++++++ src/controller/Session.ts | 97 ------------------------ src/controller/User.ts | 139 ++++++++++++++++++++++++++++++++-- src/controller/index.ts | 25 +++--- src/index.ts | 44 +++++------ src/model/ActivityLog.ts | 101 ++++++++++++++++++++++++ src/model/Base.ts | 2 +- src/model/User.ts | 115 ++++++++++++++++------------ src/model/index.ts | 9 ++- src/utility.ts | 12 +++ 13 files changed, 527 insertions(+), 250 deletions(-) create mode 100644 src/controller/ActivityLog.ts create mode 100644 src/controller/Base.ts delete mode 100644 src/controller/Session.ts create mode 100644 src/model/ActivityLog.ts diff --git a/package.json b/package.json index 35ca56b..6f5bde6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kys-service", - "version": "0.7.0", + "version": "0.9.0", "license": "AGPL-3.0", "author": "shiy2008@gmail.com", "description": "RESTful API service of KaiYuanShe", @@ -32,8 +32,9 @@ "koa2-swagger-ui": "^5.10.0", "koagger": "^0.3.0", "koajax": "^3.0.2", + "marked": "^14.1.3", "mobx": "^6.13.3", - "mobx-lark": "^1.1.1", + "mobx-lark": "^2.0.0-rc.3", "mobx-restful": "^1.0.1", "pg": "^8.13.0", "pg-connection-string": "^2.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 581f0df..ec572ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,12 +65,15 @@ importers: koajax: specifier: ^3.0.2 version: 3.0.2(jsdom@24.1.3)(typescript@5.6.3) + marked: + specifier: ^14.1.3 + version: 14.1.3 mobx: specifier: ^6.13.3 version: 6.13.3 mobx-lark: - specifier: ^1.1.1 - version: 1.1.1(jsdom@24.1.3)(mobx@6.13.3)(typescript@5.6.3) + specifier: ^2.0.0-rc.3 + version: 2.0.0-rc.3(jsdom@24.1.3)(mobx@6.13.3)(typescript@5.6.3) mobx-restful: specifier: ^1.0.1 version: 1.0.1(jsdom@24.1.3)(mobx@6.13.3)(typescript@5.6.3) @@ -1020,8 +1023,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.36: - resolution: {integrity: sha512-HYTX8tKge/VNp6FGO+f/uVDmUkq+cEfcxYhKf15Akc4M5yxt5YmorwlAitKWjWhWQnKcDRBAQKXkhqqXMqcrjw==} + electron-to-chromium@1.5.38: + resolution: {integrity: sha512-VbeVexmZ1IFh+5EfrYz1I0HTzHVIlJa112UEWhciPyeOcKJGeTv6N8WnG4wsQB81DGCaVEGhpSb6o6a8WYFXXg==} element-internals-polyfill@1.3.12: resolution: {integrity: sha512-KW1k+cMGwXlx3X9nqhgmuElAfR/c/ccFt0pG4KpwK++Mx9Y+mPExxJW+jgQnqux/NQrJejgOxxg4Naf3f6y67Q==} @@ -1508,9 +1511,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - iterable-observer@1.1.0: - resolution: {integrity: sha512-3H7N2wUtGpz5A/y4MFaP15sXxQyBTgnmq/LFMUbOErj+V9VgJY53Hd23mj33YEDap6qF22OEoV+19ATh+3+sQg==} - iterator-helpers-polyfill@3.0.1: resolution: {integrity: sha512-9uSoKErC0+TG7uoXlv5k7rs196/l/VGr9hb9KbptpMhszsSksxJCwetp0p7FvgM3SwxlxgEkvokmeOi02PARlQ==} engines: {chrome: '>=63', firefox: '>=57', node: '>=10.0.0', safari: '>=11'} @@ -1608,12 +1608,6 @@ packages: koagger@0.3.0: resolution: {integrity: sha512-+XbZppQaY/6ajAxejW9CLpzs4a80lJ5JzRw8W8zax/34SyUdEVeRM2YMSk3jOn8JjFU3ZCBpQf0wVoGk+52f0g==} - koajax@0.9.6: - resolution: {integrity: sha512-Cv5HH7igfN7HEGLwRzu4TEzXLTm3QSpbR48Gif9dhPPBNKZ8ELS1bbh3Dik1s83vpKnnjC+DcFU8ql+LwNg3tQ==} - deprecated: Don't use versions with old API & bugs - peerDependencies: - jsdom: '>=21' - koajax@3.0.2: resolution: {integrity: sha512-2l6V9BSnil+3vxJSSi6rceOp73q6Iw8KYzu8Yfn0jEyxsGmVXt0rFvfd5BvEmnVZzDPQp7EzqusuQoTIsOx4cQ==} peerDependencies: @@ -1709,6 +1703,11 @@ packages: map-stream@0.1.0: resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==} + marked@14.1.3: + resolution: {integrity: sha512-ZibJqTULGlt9g5k4VMARAktMAjXoVnnr+Y3aCqW1oDftcV4BA3UmrBifzXoZyenHRk75csiPu9iwsTj4VNBT0g==} + engines: {node: '>= 18'} + hasBin: true + marked@4.3.0: resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} engines: {node: '>= 12'} @@ -1832,17 +1831,10 @@ packages: engines: {node: '>=10'} hasBin: true - mobx-lark@1.1.1: - resolution: {integrity: sha512-O56Vx+4kRvgx8DxO4jc1sgw8SCgb53XmRiCJ3mtheASeDAJbAXYGYifVhNQUb3DGjxEkMCLvH0tn61mLMnfibg==} - deprecated: Don't use versions with old API & bugs - peerDependencies: - mobx: '>=4 <6.11' - - mobx-restful@0.6.12: - resolution: {integrity: sha512-T9h++i/Ca31FPiBDNPux4b4kygeBk9SYxn3Ol7Mg9rNLAd/0j2eFG7UQTeL3qgFwEDzq8M1s2SoH0SSZjm9lag==} - deprecated: Don't use versions with old API & bugs + mobx-lark@2.0.0-rc.3: + resolution: {integrity: sha512-LlyPxcpCUiN0gP9ScEi8x0IbT0DLido5me9LA7nGer9EoaRHam1fkCk7lEYvNGDLdEjMb3S6FEnNlbxEKb40/w==} peerDependencies: - mobx: '>=4' + mobx: '>=6.11' mobx-restful@1.0.1: resolution: {integrity: sha512-jxX2anGxUc/E71pDAZn3SWjJhelRjNCbYHggccmSzawAEYI9AiKc0gwmPmifR0zwajVi3RpBzfqF4mQgt0TpCQ==} @@ -1882,8 +1874,8 @@ packages: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} - node-abi@3.68.0: - resolution: {integrity: sha512-7vbj10trelExNjFSBm5kTvZXXa7pZyKWx9RCKIyqe6I9Ev3IzGpQoqBP3a+cOdxY+pWj6VkP28n/2wWysBHD/A==} + node-abi@3.69.0: + resolution: {integrity: sha512-H/k5/+HXto3xXTcqTIl3DAWaelvNVYSoZ2IJVDFJEoYyZYcoRhcRy+1WMMhsKAG+UU7wSCI3DRurJ0DxFMXvyg==} engines: {node: '>=10'} node-addon-api@7.1.1: @@ -3689,7 +3681,7 @@ snapshots: browserslist@4.24.0: dependencies: caniuse-lite: 1.0.30001668 - electron-to-chromium: 1.5.36 + electron-to-chromium: 1.5.38 node-releases: 2.0.18 update-browserslist-db: 1.1.1(browserslist@4.24.0) @@ -4020,7 +4012,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.36: {} + electron-to-chromium@1.5.38: {} element-internals-polyfill@1.3.12: {} @@ -4591,10 +4583,6 @@ snapshots: isexe@2.0.0: {} - iterable-observer@1.1.0: - dependencies: - '@swc/helpers': 0.5.13 - iterator-helpers-polyfill@3.0.1: {} jackspeak@3.4.3: @@ -4768,16 +4756,6 @@ snapshots: - '@types/koa' - supports-color - koajax@0.9.6(jsdom@24.1.3)(typescript@5.6.3): - dependencies: - '@swc/helpers': 0.5.13 - iterable-observer: 1.1.0 - jsdom: 24.1.3 - regenerator-runtime: 0.14.1 - web-utility: 4.4.1(typescript@5.6.3) - transitivePeerDependencies: - - typescript - koajax@3.0.2(jsdom@24.1.3)(typescript@5.6.3): dependencies: '@swc/helpers': 0.5.13 @@ -4897,6 +4875,8 @@ snapshots: map-stream@0.1.0: {} + marked@14.1.3: {} + marked@4.3.0: {} media-typer@0.3.0: {} @@ -4997,25 +4977,12 @@ snapshots: mkdirp@2.1.6: {} - mobx-lark@1.1.1(jsdom@24.1.3)(mobx@6.13.3)(typescript@5.6.3): + mobx-lark@2.0.0-rc.3(jsdom@24.1.3)(mobx@6.13.3)(typescript@5.6.3): dependencies: '@swc/helpers': 0.5.13 - koajax: 0.9.6(jsdom@24.1.3)(typescript@5.6.3) - mobx: 6.13.3 - mobx-restful: 0.6.12(jsdom@24.1.3)(mobx@6.13.3)(typescript@5.6.3) - regenerator-runtime: 0.14.1 - web-utility: 4.4.1(typescript@5.6.3) - transitivePeerDependencies: - - jsdom - - typescript - - mobx-restful@0.6.12(jsdom@24.1.3)(mobx@6.13.3)(typescript@5.6.3): - dependencies: - '@swc/helpers': 0.5.13 - class-validator: 0.14.1 - koajax: 0.9.6(jsdom@24.1.3)(typescript@5.6.3) + koajax: 3.0.2(jsdom@24.1.3)(typescript@5.6.3) mobx: 6.13.3 - reflect-metadata: 0.1.14 + mobx-restful: 1.0.1(jsdom@24.1.3)(mobx@6.13.3)(typescript@5.6.3) regenerator-runtime: 0.14.1 web-utility: 4.4.1(typescript@5.6.3) transitivePeerDependencies: @@ -5071,7 +5038,7 @@ snapshots: netmask@2.0.2: {} - node-abi@3.68.0: + node-abi@3.69.0: dependencies: semver: 7.6.3 @@ -5343,7 +5310,7 @@ snapshots: minimist: 1.2.8 mkdirp-classic: 0.5.3 napi-build-utils: 1.0.2 - node-abi: 3.68.0 + node-abi: 3.69.0 pump: 3.0.2 rc: 1.2.8 simple-get: 4.0.1 diff --git a/src/controller/ActivityLog.ts b/src/controller/ActivityLog.ts new file mode 100644 index 0000000..e7b7d4c --- /dev/null +++ b/src/controller/ActivityLog.ts @@ -0,0 +1,114 @@ +import { Get, JsonController, Param, QueryParams } from 'routing-controllers'; +import { ResponseSchema } from 'routing-controllers-openapi'; +import { FindOptionsWhere } from 'typeorm'; + +import { + ActivityLog, + ActivityLogFilter, + ActivityLogListChunk, + BaseFilter, + dataSource, + LogableTable, + Operation, + User, + UserRank, + UserRankListChunk +} from '../model'; + +const store = dataSource.getRepository(ActivityLog), + userStore = dataSource.getRepository(User), + userRankStore = dataSource.getRepository(UserRank); + +@JsonController('/activity-log') +export class ActivityLogController { + static logCreate( + createdBy: User, + tableName: ActivityLog['tableName'], + recordId: number + ) { + const operation = Operation.Create; + + return store.save({ createdBy, operation, tableName, recordId }); + } + + static logUpdate( + createdBy: User, + tableName: ActivityLog['tableName'], + recordId: number + ) { + const operation = Operation.Update; + + return store.save({ createdBy, operation, tableName, recordId }); + } + + static logDelete( + createdBy: User, + tableName: ActivityLog['tableName'], + recordId: number + ) { + const operation = Operation.Delete; + + return store.save({ createdBy, operation, tableName, recordId }); + } + + @Get('/user-rank') + @ResponseSchema(UserRankListChunk) + async getUserRankList(@QueryParams() { pageSize, pageIndex }: BaseFilter) { + const skip = pageSize * (pageIndex - 1); + + const [list, count] = await userRankStore.findAndCount({ + order: { score: 'DESC' }, + skip, + take: pageSize + }); + for (let i = 0, item: UserRank; (item = list[i]); i++) { + item.rank = skip + i + 1; + item.user = await userStore.findOneBy({ id: item.userId }); + } + return { list, count }; + } + + @Get('/user/:id') + @ResponseSchema(ActivityLogListChunk) + getUserList( + @Param('id') id: number, + @QueryParams() { operation, pageSize, pageIndex }: ActivityLogFilter + ) { + return this.queryList( + { operation, createdBy: { id } }, + { pageSize, pageIndex } + ); + } + + @Get('/:table/:id') + @ResponseSchema(ActivityLogListChunk) + getList( + @Param('table') tableName: keyof typeof LogableTable, + @Param('id') recordId: number, + @QueryParams() { operation, pageSize, pageIndex }: ActivityLogFilter + ) { + return this.queryList( + { operation, tableName, recordId }, + { pageSize, pageIndex } + ); + } + + async queryList( + where: FindOptionsWhere, + { pageSize, pageIndex }: BaseFilter + ) { + const [list, count] = await store.findAndCount({ + where, + relations: ['createdBy'], + skip: pageSize * (pageIndex - 1), + take: pageSize + }); + + for (const activity of list) + activity.record = await dataSource + .getRepository(activity.tableName) + .findOneBy({ id: activity.recordId }); + + return { list, count }; + } +} diff --git a/src/controller/Base.ts b/src/controller/Base.ts new file mode 100644 index 0000000..41473b3 --- /dev/null +++ b/src/controller/Base.ts @@ -0,0 +1,29 @@ +import { marked } from 'marked'; +import { Controller, Get, HeaderParam, HttpCode } from 'routing-controllers'; + +import { isProduct } from '../utility'; + +@Controller() +export class BaseController { + static entryOf(host: string) { + host = 'http://' + host; + + return ` +- HTTP served at ${host} +- Swagger API served at ${host}/docs/ +- Swagger API exposed at ${host}/docs/spec +${isProduct ? '' : `- Mock API served at ${host}/mock/`} +`; + } + + @Get('/_health') + @HttpCode(200) + getHealthStatus() { + return ''; + } + + @Get() + getIndex(@HeaderParam('host') host: string) { + return marked(BaseController.entryOf(host)); + } +} diff --git a/src/controller/Session.ts b/src/controller/Session.ts deleted file mode 100644 index 42f435c..0000000 --- a/src/controller/Session.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { JsonWebTokenError, sign, verify } from 'jsonwebtoken'; -import { - Authorized, - CurrentUser, - Get, - HeaderParam, - JsonController, - Post -} from 'routing-controllers'; -import { ResponseSchema } from 'routing-controllers-openapi'; -import { Repository } from 'typeorm'; - -import { AuthingSession, JWTAction, User, dataSource } from '../model'; -import { AUTHING_APP_SECRET } from '../utility'; - -@JsonController('/user/session') -export class SessionController { - store = dataSource.getRepository(User); - - static async register( - store: Repository, - data: Partial> - ) { - const { id } = await store.save(data); - - return store.findOne({ where: { id } }); - } - - static signToken(user: User) { - return { ...user, token: sign({ ...user }, AUTHING_APP_SECRET) }; - } - - static fixPhoneNumber(raw: string) { - return raw.startsWith('+') ? raw : `+86${raw}`; - } - - static getAuthingUser(token: string): AuthingSession { - var { phone_number, ...session } = verify( - token.split(/\s+/)[1], - AUTHING_APP_SECRET - ) as AuthingSession; - - if (phone_number) phone_number = this.fixPhoneNumber(phone_number); - - return { ...session, phone_number }; - } - - static async getSession({ context: { state } }: JWTAction) { - if (state instanceof JsonWebTokenError) return console.error(state); - - const { user } = state; - - if (!user) return; - - if ('userpool_id' in user) - return dataSource.getRepository(User).findOne({ - where: { - mobilePhone: this.fixPhoneNumber(user.phone_number) - } - }); - - delete user.iat; - - return user; - } - - @Get() - @Authorized() - @ResponseSchema(User) - getSession(@CurrentUser() user: User) { - return user; - } - - @Post('/authing') - @ResponseSchema(User) - async signInAuthing( - @HeaderParam('Authorization', { required: true }) token: string - ) { - const { - sub, - phone_number: mobilePhone, - nickname, - picture - } = SessionController.getAuthingUser(token); - - const existed = await this.store.findOne({ where: { mobilePhone } }); - - const registered = await SessionController.register(this.store, { - ...existed, - uuid: sub, - mobilePhone, - nickName: nickname, - avatar: picture - }); - return SessionController.signToken(registered); - } -} diff --git a/src/controller/User.ts b/src/controller/User.ts index 5fc38c5..eab7b3c 100644 --- a/src/controller/User.ts +++ b/src/controller/User.ts @@ -1,16 +1,143 @@ -import { Get, JsonController, OnNull, Param } from 'routing-controllers'; +import { createHash } from 'crypto'; +import { JsonWebTokenError, sign } from 'jsonwebtoken'; +import { + Authorized, + Body, + CurrentUser, + Delete, + ForbiddenError, + Get, + HttpCode, + JsonController, + OnNull, + OnUndefined, + Param, + Post, + Put, + QueryParams +} from 'routing-controllers'; import { ResponseSchema } from 'routing-controllers-openapi'; -import { User, dataSource } from '../model'; +import { + dataSource, + JWTAction, + SignInData, + User, + UserFilter, + UserListChunk +} from '../model'; +import { AUTHING_APP_SECRET, searchConditionOf } from '../utility'; +import { ActivityLogController } from './ActivityLog'; + +const store = dataSource.getRepository(User); @JsonController('/user') export class UserController { - store = dataSource.getRepository(User); + static encrypt = (raw: string) => + createHash('sha1') + .update(AUTHING_APP_SECRET + raw) + .digest('hex'); + + static sign = (user: User): User => ({ + ...user, + token: sign({ ...user }, AUTHING_APP_SECRET) + }); + + static async signUp({ mobilePhone, password }: SignInData) { + const { password: _, ...user } = await store.save({ + name: mobilePhone, + mobilePhone, + password: UserController.encrypt(password) + }); + await ActivityLogController.logCreate(user, 'User', user.id); + + return user; + } + + static getSession({ context: { state } }: JWTAction) { + return state instanceof JsonWebTokenError + ? console.error(state) + : state.user; + } + + @Get('/session') + @Authorized() + @ResponseSchema(User) + getSession(@CurrentUser() user: User) { + return user; + } + + @Post('/session') + @HttpCode(201) + @ResponseSchema(User) + async signIn(@Body() { mobilePhone, password }: SignInData): Promise { + const user = await store.findOneBy({ + mobilePhone, + password: UserController.encrypt(password) + }); + if (!user) throw new ForbiddenError(); + + return UserController.sign(user); + } - @Get('/:uuid') + @Post() + @HttpCode(201) + @ResponseSchema(User) + signUp(@Body() data: SignInData) { + return UserController.signUp(data); + } + + @Put('/:id') + @Authorized() + @ResponseSchema(User) + async updateOne( + @Param('id') id: number, + @CurrentUser() updatedBy: User, + @Body() { password, ...data }: User + ) { + if (id !== updatedBy.id) throw new ForbiddenError(); + + const saved = await store.save({ + ...data, + password: password && UserController.encrypt(password), + id + }); + await ActivityLogController.logUpdate(updatedBy, 'User', id); + + return UserController.sign(saved); + } + + @Get('/:id') @OnNull(404) @ResponseSchema(User) - getOne(@Param('uuid') uuid: string) { - return this.store.findOne({ where: { uuid } }); + getOne(@Param('id') id: number) { + return store.findOne({ where: { id } }); + } + + @Delete('/:id') + @Authorized() + @OnUndefined(204) + async deleteOne(@Param('id') id: number, @CurrentUser() deletedBy: User) { + if (id == deletedBy.id) throw new ForbiddenError(); + + await store.delete(id); + } + + @Get() + @ResponseSchema(UserListChunk) + async getList( + @QueryParams() { gender, keywords, pageSize, pageIndex }: UserFilter + ) { + const where = searchConditionOf( + ['mobilePhone', 'nickName'], + keywords, + gender && { gender } + ); + const [list, count] = await store.findAndCount({ + where, + skip: pageSize * (pageIndex - 1), + take: pageSize + }); + return { list, count }; } } diff --git a/src/controller/index.ts b/src/controller/index.ts index 393c089..0fe0fa4 100644 --- a/src/controller/index.ts +++ b/src/controller/index.ts @@ -5,16 +5,23 @@ import { isProduct } from '../utility'; import { CheckEventController } from './CheckEvent'; import { CrawlerController } from './Crawler'; import { KTokenController } from './KToken'; -import { SessionController } from './Session'; import { UserController } from './User'; +import { ActivityLogController } from './ActivityLog'; +import { BaseController } from './Base'; -export const { router, swagger, mocker } = createAPI({ +export * from './Base'; +export * from './User'; +export * from './ActivityLog'; + +export const controllers = [ + UserController, + CheckEventController, + CrawlerController, + KTokenController, + ActivityLogController, + BaseController +]; +export const { swagger, mocker, router } = createAPI({ mock: !isProduct, - controllers: [ - SessionController, - UserController, - CheckEventController, - CrawlerController, - KTokenController - ] + controllers }); diff --git a/src/index.ts b/src/index.ts index 9990089..ba40d36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,27 @@ -import 'dotenv/config'; +import { config } from 'dotenv'; import { installIntoGlobal } from 'iterator-helpers-polyfill'; -installIntoGlobal(); import 'reflect-metadata'; -import Koa, { Context } from 'koa'; +installIntoGlobal(); + +config({ path: [`.env.${process.env.NODE_ENV}.local`, '.env.local', '.env'] }); + +import Koa from 'koa'; import jwt from 'koa-jwt'; import KoaLogger from 'koa-logger'; import { useKoaServer } from 'routing-controllers'; -import { mocker, router, swagger } from './controller'; -import { SessionController } from './controller/Session'; +import { + BaseController, + controllers, + mocker, + swagger, + UserController +} from './controller'; import { dataSource } from './model'; -import { AUTHING_APP_SECRET, PORT, WEB_HOOK_TOKEN, isProduct } from './utility'; +import { AUTHING_APP_SECRET, isProduct, PORT } from './utility'; -const HOST = `http://localhost:${PORT}`, +const HOST = `localhost:${PORT}`, app = new Koa() .use(KoaLogger()) .use(swagger({ exposeSpec: true })) @@ -22,31 +30,17 @@ const HOST = `http://localhost:${PORT}`, if (!isProduct) app.use(mocker()); useKoaServer(app, { - ...router, + controllers, cors: true, - authorizationChecker: async action => { - const [_, token] = - (action.context as Context).get('Authorization')?.split(/\s+/) || - []; - - return ( - token === WEB_HOOK_TOKEN || - !!(await SessionController.getSession(action)) - ); - }, - currentUserChecker: action => SessionController.getSession(action) + authorizationChecker: action => !!UserController.getSession(action), + currentUserChecker: UserController.getSession }); console.time('Server boot'); dataSource.initialize().then(() => app.listen(PORT, () => { - console.log(` -HTTP served at ${HOST} -Swagger API served at ${HOST}/docs/ -Swagger API exposed at ${HOST}/docs/spec`); - - if (!isProduct) console.log(`Mock API served at ${HOST}/mock/\n`); + console.log(BaseController.entryOf(HOST)); console.timeEnd('Server boot'); }) diff --git a/src/model/ActivityLog.ts b/src/model/ActivityLog.ts new file mode 100644 index 0000000..12c3a8c --- /dev/null +++ b/src/model/ActivityLog.ts @@ -0,0 +1,101 @@ +import { Type } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsObject, + IsOptional, + Min, + ValidateNested +} from 'class-validator'; +import { Column, Entity, ViewColumn, ViewEntity } from 'typeorm'; + +import { Base, BaseFilter, InputData, ListChunk } from './Base'; +import { User, UserBase } from './User'; + +export enum Operation { + Create = 'create', + Update = 'update', + Delete = 'delete' +} + +export const LogableTable = { User }; + +const LogableTableEnum = Object.fromEntries( + Object.entries(LogableTable).map(([key]) => [key, key]) +); + +@Entity() +export class ActivityLog extends UserBase { + @IsEnum(Operation) + @Column({ type: 'simple-enum', enum: Operation }) + operation: Operation; + + @IsEnum(LogableTableEnum) + @Column({ type: 'simple-enum', enum: LogableTableEnum }) + tableName: keyof typeof LogableTable; + + @IsInt() + @Min(1) + @Column() + recordId: number; + + @IsObject() + @IsOptional() + record?: Base; +} + +export class ActivityLogFilter + extends BaseFilter + implements Partial> +{ + @IsEnum(Operation) + @IsOptional() + operation?: Operation; +} + +export class ActivityLogListChunk implements ListChunk { + @IsInt() + @Min(0) + count: number; + + @Type(() => ActivityLog) + @ValidateNested({ each: true }) + list: ActivityLog[]; +} + +@ViewEntity({ + expression: connection => + connection + .createQueryBuilder() + .from(ActivityLog, 'al') + .groupBy('al.createdBy') + .select('al.createdBy.id', 'userId') + .addSelect('COUNT(al.id)', 'score') +}) +export class UserRank { + @IsInt() + @Min(1) + @ViewColumn() + userId: number; + + @Type(() => User) + @ValidateNested() + user: User; + + @ViewColumn() + score: number; + + @IsInt() + @Min(1) + rank: number; +} + +export class UserRankListChunk implements ListChunk { + @IsInt() + @Min(0) + count: number; + + @Type(() => UserRank) + @ValidateNested({ each: true }) + list: UserRank[]; +} diff --git a/src/model/Base.ts b/src/model/Base.ts index c58cd73..e77f92b 100644 --- a/src/model/Base.ts +++ b/src/model/Base.ts @@ -54,7 +54,7 @@ export class BaseFilter { keywords?: string; } -export interface ListChunk { +export interface ListChunk { count: number; list: T[]; } diff --git a/src/model/User.ts b/src/model/User.ts index 0402590..53f4c50 100644 --- a/src/model/User.ts +++ b/src/model/User.ts @@ -1,19 +1,22 @@ import { Type } from 'class-transformer'; import { IsEnum, + IsInt, IsJWT, IsMobilePhone, IsOptional, IsString, + IsStrongPassword, IsUrl, + Min, ValidateNested } from 'class-validator'; -import { JsonWebTokenError, JwtPayload } from 'jsonwebtoken'; +import { JsonWebTokenError } from 'jsonwebtoken'; import { ParameterizedContext } from 'koa'; import { NewData } from 'mobx-restful'; import { Column, Entity, ManyToOne } from 'typeorm'; -import { Base } from './Base'; +import { Base, BaseFilter, InputData, ListChunk } from './Base'; export enum Gender { Female = 0, @@ -24,6 +27,7 @@ export enum Gender { @Entity() export class User extends Base { @IsString() + @IsOptional() @Column({ nullable: true }) uuid: string; @@ -46,6 +50,11 @@ export class User extends Base { @Column({ nullable: true }) avatar?: string; + @IsStrongPassword() + @IsOptional() + @Column({ nullable: true, select: false }) + password?: string; + @IsJWT() @IsOptional() token?: string; @@ -53,9 +62,34 @@ export class User extends Base { iat?: number; } +export class UserFilter extends BaseFilter implements Partial> { + @IsMobilePhone() + @IsOptional() + mobilePhone?: string; + + @IsString() + @IsOptional() + nickName?: string; + + @IsEnum(Gender) + @IsOptional() + gender?: Gender; +} + +export class UserListChunk implements ListChunk { + @IsInt() + @Min(0) + count: number; + + @Type(() => User) + @ValidateNested({ each: true }) + list: User[]; +} + export abstract class UserBase extends Base { @Type(() => User) @ValidateNested() + @IsOptional() @ManyToOne(() => User) createdBy: User; @@ -68,52 +102,39 @@ export abstract class UserBase extends Base { export type UserInputData = NewData, Base>; -export type AuthingAddress = Partial< - Record<'country' | 'postal_code' | 'region' | 'formatted', string> ->; - -export type AuthingUser = Record< - 'type' | 'userPoolId' | 'appId' | 'id' | '_id' | 'userId' | 'clientId', - string -> & - Partial< - Record<'email' | 'phone' | 'username' | 'unionid' | 'openid', string> - >; - -export interface AuthingSession - extends JwtPayload, - Pick, - Record<'userpool_id' | 'gender' | 'picture', string>, - Partial< - Record< - | 'external_id' - | 'email' - | 'website' - | 'phone_number' - | 'name' - | 'preferred_username' - | 'nickname' - | 'family_name' - | 'middle_name' - | 'given_name' - | 'birthdate' - | 'locale' - | 'zoneinfo', - string - > - > { - phone_number_verified: boolean; - email_verified: boolean; - - data: AuthingUser; - profile?: any; - address: AuthingAddress; - - updated_at: Date; +export class UserBaseFilter + extends BaseFilter + implements Partial> +{ + @IsInt() + @Min(1) + @IsOptional() + createdBy?: number; + + @IsInt() + @Min(1) + @IsOptional() + updatedBy?: number; +} + +export class SignInData + implements Required> +{ + @IsMobilePhone() + mobilePhone: string; + + @IsString() + password: string; +} + +export class SignUpData + extends SignInData + implements Required> +{ + @IsString() + nickName: string; } export interface JWTAction { - context?: ParameterizedContext< - JsonWebTokenError | { user: User | AuthingSession } - >; + context?: ParameterizedContext; } diff --git a/src/model/index.ts b/src/model/index.ts index 5184ff8..38d9189 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -5,12 +5,14 @@ import { SqliteConnectionOptions } from 'typeorm/driver/sqlite/SqliteConnectionO import { DATABASE_URL, isProduct } from '../utility'; import { CheckEvent } from './CheckEvent'; import { User } from './User'; +import { ActivityLog } from './ActivityLog'; export * from './Base'; export * from './CheckEvent'; export * from './Crawler'; export * from './KToken'; export * from './User'; +export * from './ActivityLog'; const { ssl, host, port, user, password, database } = isProduct ? parse(DATABASE_URL) @@ -18,10 +20,11 @@ const { ssl, host, port, user, password, database } = isProduct const commonOptions: Pick< SqliteConnectionOptions, - 'synchronize' | 'entities' | 'migrations' + 'logging' | 'synchronize' | 'entities' | 'migrations' > = { + logging: true, synchronize: true, - entities: [User, CheckEvent], + entities: [User, ActivityLog, CheckEvent], migrations: [`${isProduct ? '.tmp' : 'migration'}/*.ts`] }; @@ -34,12 +37,10 @@ export const dataSource = isProduct username: user, password, database, - logging: true, ...commonOptions }) : new DataSource({ type: 'sqlite', database: '.tmp/test.db', - logging: false, ...commonOptions }); diff --git a/src/utility.ts b/src/utility.ts index 6c2a0f1..0ae547f 100644 --- a/src/utility.ts +++ b/src/utility.ts @@ -1,6 +1,9 @@ import { BlobServiceClient } from '@azure/storage-blob'; import { fromBuffer } from 'file-type'; import { BiDataTable, LarkApp, TableRecordFields } from 'mobx-lark'; +import { FindOptionsWhere, ILike } from 'typeorm'; + +import { Base } from './model'; export const { NODE_ENV, @@ -18,6 +21,15 @@ export const { export const isProduct = NODE_ENV === 'production'; +export const searchConditionOf = ( + keys: (keyof T)[], + keywords = '', + filter?: FindOptionsWhere +) => + keywords + ? keys.map(key => ({ [key]: ILike(`%${keywords}%`), ...filter })) + : filter; + export const lark = new LarkApp({ id: LARK_APP_ID, secret: LARK_APP_SECRET