From ceea4920f6aec2ec42fe2342c57ef45d7c546ef2 Mon Sep 17 00:00:00 2001 From: "maruyama.t" Date: Tue, 4 Jul 2023 08:58:28 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=20186:=20API=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=EF=BC=88=E3=82=AB=E3=83=BC=E3=83=89=E3=83=A9=E3=82=A4=E3=82=BB?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E7=99=BA=E8=A1=8CAPI=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task1992: API実装(カードライセンス発行API)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/1992) - タスク 1992: API実装(カードライセンス発行API) -カードライセンス発行APIを実装 ## レビューポイント - DB登録時の処理が適切かどうか ## UIの変更 なし ## 動作確認状況 ユニットテスト実施済み ローカルでの動作確認実施済み ## 補足 なし --- dictation_server/src/constants/index.ts | 15 ++ .../features/licenses/licenses.controller.ts | 25 +++- .../licenses/licenses.service.spec.ts | 107 +++++++++++++- .../src/features/licenses/licenses.service.ts | 46 ++++++ .../licenses/test/liscense.service.mock.ts | 21 ++- .../src/features/licenses/test/utility.ts | 57 ++++++++ .../licenses/entity/license.entity.ts | 34 ++++- .../licenses/licenses.repository.module.ts | 4 +- .../licenses/licenses.repository.service.ts | 132 +++++++++++++++++- 9 files changed, 423 insertions(+), 18 deletions(-) create mode 100644 dictation_server/src/features/licenses/test/utility.ts diff --git a/dictation_server/src/constants/index.ts b/dictation_server/src/constants/index.ts index d209424..3bae42a 100644 --- a/dictation_server/src/constants/index.ts +++ b/dictation_server/src/constants/index.ts @@ -113,6 +113,15 @@ export const LICENSE_STATUS_ISSUE_REQUESTING = 'Issue Requesting'; */ export const LICENSE_STATUS_ISSUED = 'Issued'; +/** + * ライセンス種別 + * @const {string[]} + */ +export const LICENSE_TYPE = { + TRIAL: 'TRIAL', + NORMAL: 'NORMAL', + CARD: 'CARD', +} as const; /** * ライセンス状態 * @const {string[]} @@ -130,6 +139,12 @@ export const LICENSE_ALLOCATED_STATUS = { */ export const LICENSE_EXPIRATION_THRESHOLD_DAYS = 14; +/** + * カードライセンスの桁数 + * @const {number} + */ +export const CARD_LICENSE_LENGTH = 20; + /** * 音声ファイルに紐づくオプションアイテムの数 * @const {string} diff --git a/dictation_server/src/features/licenses/licenses.controller.ts b/dictation_server/src/features/licenses/licenses.controller.ts index 6c6f507..9a79486 100644 --- a/dictation_server/src/features/licenses/licenses.controller.ts +++ b/dictation_server/src/features/licenses/licenses.controller.ts @@ -5,6 +5,7 @@ import { Post, Req, UseGuards, + HttpException, } from '@nestjs/common'; import { ApiResponse, @@ -27,6 +28,7 @@ import { AuthGuard } from '../../common/guards/auth/authguards'; import { RoleGuard } from '../../common/guards/role/roleguards'; import { ADMIN_ROLES } from '../../constants'; import jwt from 'jsonwebtoken'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; @ApiTags('licenses') @Controller('licenses') @@ -95,6 +97,7 @@ export class LicensesController { @ApiOperation({ operationId: 'issueCardLicenses' }) @ApiBearerAuth() @UseGuards(AuthGuard) + @UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] })) @Post('/cards') async issueCardLicenses( @Req() req: Request, @@ -103,13 +106,21 @@ export class LicensesController { console.log(req.header('Authorization')); console.log(body); - // レスポンス値のサンプル - const cardLicenseKeys: string[] = [ - '3S5F9P7L4X1J6G2M8Q0Y', - '9R7K2U1H5V3B6M0D8W4C', - '2L0X5Y9P6U7Q1G4C3W8N', - ]; + const accessToken = retrieveAuthorizationToken(req); + const payload = jwt.decode(accessToken, { json: true }) as AccessToken; - return { cardLicenseKeys }; + // 第一階層以外は401を返す(後々UseGuardsで弾く) + if (payload.tier != 1) { + throw new HttpException( + makeErrorResponse('E000108'), + HttpStatus.UNAUTHORIZED, + ); + } + const cardLicenseKeys = await this.licensesService.issueCardLicenseKeys( + payload.userId, + body.createCount, + ); + + return cardLicenseKeys; } } diff --git a/dictation_server/src/features/licenses/licenses.service.spec.ts b/dictation_server/src/features/licenses/licenses.service.spec.ts index 599a289..446326e 100644 --- a/dictation_server/src/features/licenses/licenses.service.spec.ts +++ b/dictation_server/src/features/licenses/licenses.service.spec.ts @@ -1,5 +1,9 @@ import { AccessToken } from '../../common/token'; -import { CreateOrdersRequest } from './types/types'; +import { + CreateOrdersRequest, + IssueCardLicensesRequest, + IssueCardLicensesResponse, +} from './types/types'; import { makeDefaultAccountsRepositoryMockValue, makeDefaultLicensesRepositoryMockValue, @@ -9,6 +13,14 @@ import { import { makeErrorResponse } from '../../common/error/makeErrorResponse'; import { HttpException, HttpStatus } from '@nestjs/common'; import { PoNumberAlreadyExistError } from '../../repositories/licenses/errors/types'; +import { LicensesService } from './licenses.service'; +import { makeTestingModule } from '../../common/test/modules'; +import { DataSource } from 'typeorm'; +import { + createAccount, + createUser, + selectCardLicensesCount, +} from './test/utility'; describe('LicensesService', () => { it('ライセンス注文が完了する', async () => { @@ -109,4 +121,97 @@ describe('LicensesService', () => { ), ); }); + it('カードライセンス発行が完了する', async () => { + const lisencesRepositoryMockValue = + makeDefaultLicensesRepositoryMockValue(); + const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const accountsRepositoryMockValue = + makeDefaultAccountsRepositoryMockValue(); + const service = await makeLicensesServiceMock( + lisencesRepositoryMockValue, + usersRepositoryMockValue, + accountsRepositoryMockValue, + ); + const body = new IssueCardLicensesRequest(); + const token: AccessToken = { userId: '0001', role: '', tier: 5 }; + body.createCount = 10; + const issueCardLicensesResponse: IssueCardLicensesResponse = { + cardLicenseKeys: [ + 'WZCETXC0Z9PQZ9GKRGGY', + 'F0JD7EZEDBH4PQRQ83YF', + 'H0HXBP5K9RW7T7JSVDJV', + 'HKIWX54EESYL4X132223', + '363E81JR460UBHXGFXFI', + '70IKAPV9K6YMEVLTOXBY', + '1RJY1TRRYYTGF1LL9WLU', + 'BXM0HKFO7IULTL0A1B36', + 'XYLEWNY2LR6Q657CZE41', + 'AEJWRFFSWRQYQQJ6WVLV', + ], + }; + expect( + await service.issueCardLicenseKeys(token.userId, body.createCount), + ).toEqual(issueCardLicensesResponse); + }); + it('カードライセンス発行に失敗した場合、エラーになる', async () => { + const lisencesRepositoryMockValue = + makeDefaultLicensesRepositoryMockValue(); + lisencesRepositoryMockValue.createCardLicenses = new Error('DB failed'); + const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const accountsRepositoryMockValue = + makeDefaultAccountsRepositoryMockValue(); + const service = await makeLicensesServiceMock( + lisencesRepositoryMockValue, + usersRepositoryMockValue, + accountsRepositoryMockValue, + ); + const body = new IssueCardLicensesRequest(); + const token: AccessToken = { userId: '0001', role: '', tier: 5 }; + body.createCount = 1000; + await expect( + service.issueCardLicenseKeys(token.userId, body.createCount), + ).rejects.toEqual( + new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + }); +}); + +describe('DBテスト', () => { + let source: DataSource = null; + beforeEach(async () => { + source = new DataSource({ + type: 'sqlite', + database: ':memory:', + logging: false, + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + synchronize: true, // trueにすると自動的にmigrationが行われるため注意 + }); + return source.initialize(); + }); + + afterEach(async () => { + await source.destroy(); + source = null; + }); + + it('カードライセンス発行が完了する(発行数が合っているか確認)', async () => { + const module = await makeTestingModule(source); + + const { accountId } = await createAccount(source); + const { externalId } = await createUser( + source, + accountId, + 'userId', + 'admin', + ); + + const service = module.get(LicensesService); + const issueCount = 1000; + await service.issueCardLicenseKeys(externalId, issueCount); + const dbSelectResult = await selectCardLicensesCount(source); + expect(dbSelectResult.count).toEqual(issueCount); + }); }); diff --git a/dictation_server/src/features/licenses/licenses.service.ts b/dictation_server/src/features/licenses/licenses.service.ts index 2643563..3767636 100644 --- a/dictation_server/src/features/licenses/licenses.service.ts +++ b/dictation_server/src/features/licenses/licenses.service.ts @@ -7,6 +7,7 @@ import { AccountNotFoundError } from '../../repositories/accounts/errors/types'; import { PoNumberAlreadyExistError } from '../../repositories/licenses/errors/types'; import { LicensesRepositoryService } from '../../repositories/licenses/licenses.repository.service'; import { UserNotFoundError } from '../../repositories/users/errors/types'; +import { IssueCardLicensesResponse } from './types/types'; @Injectable() export class LicensesService { @@ -38,6 +39,7 @@ export class LicensesService { myAccountId = (await this.usersRepository.findUserByExternalId(userId)) .account_id; } catch (e) { + this.logger.error(`error=${e}`); switch (e.constructor) { case UserNotFoundError: throw new HttpException( @@ -58,6 +60,7 @@ export class LicensesService { await this.accountsRepository.findAccountById(myAccountId) ).parent_account_id; } catch (e) { + this.logger.error(`error=${e}`); switch (e.constructor) { case AccountNotFoundError: throw new HttpException( @@ -94,4 +97,47 @@ export class LicensesService { } } } + async issueCardLicenseKeys( + externalId: string, + createCount: number, + ): Promise { + const issueCardLicensesResponse = new IssueCardLicensesResponse(); + let myAccountId: number; + + // ユーザIDからアカウントIDを取得する + try { + myAccountId = ( + await this.usersRepository.findUserByExternalId(externalId) + ).account_id; + } catch (e) { + this.logger.error(`error=${e}`); + switch (e.constructor) { + case UserNotFoundError: + throw new HttpException( + makeErrorResponse('E010204'), + HttpStatus.BAD_REQUEST, + ); + default: + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + try { + const licenseKeys = await this.licensesRepository.createCardLicenses( + myAccountId, + createCount, + ); + issueCardLicensesResponse.cardLicenseKeys = licenseKeys; + return issueCardLicensesResponse; + } catch (e) { + this.logger.error(`error=${e}`); + this.logger.error('get cardlicensekeys failed'); + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } } diff --git a/dictation_server/src/features/licenses/test/liscense.service.mock.ts b/dictation_server/src/features/licenses/test/liscense.service.mock.ts index bf9c4e5..bf00c7c 100644 --- a/dictation_server/src/features/licenses/test/liscense.service.mock.ts +++ b/dictation_server/src/features/licenses/test/liscense.service.mock.ts @@ -8,6 +8,7 @@ import { AccountsRepositoryService } from '../../../repositories/accounts/accoun export type LicensesRepositoryMockValue = { order: undefined | Error; + createCardLicenses: string[] | Error; }; export type AccountsRepositoryMockValue = { @@ -44,12 +45,18 @@ export const makeLicensesServiceMock = async ( export const makeLicensesRepositoryMock = ( value: LicensesRepositoryMockValue, ) => { - const { order } = value; + const { order, createCardLicenses } = value; return { order: order instanceof Error ? jest.fn, []>().mockRejectedValue(order) : jest.fn, []>().mockResolvedValue(order), + createCardLicenses: + createCardLicenses instanceof Error + ? jest.fn, []>().mockRejectedValue(createCardLicenses) + : jest + .fn, []>() + .mockResolvedValue(createCardLicenses), }; }; @@ -81,6 +88,18 @@ export const makeDefaultLicensesRepositoryMockValue = (): LicensesRepositoryMockValue => { return { order: undefined, + createCardLicenses: [ + 'WZCETXC0Z9PQZ9GKRGGY', + 'F0JD7EZEDBH4PQRQ83YF', + 'H0HXBP5K9RW7T7JSVDJV', + 'HKIWX54EESYL4X132223', + '363E81JR460UBHXGFXFI', + '70IKAPV9K6YMEVLTOXBY', + '1RJY1TRRYYTGF1LL9WLU', + 'BXM0HKFO7IULTL0A1B36', + 'XYLEWNY2LR6Q657CZE41', + 'AEJWRFFSWRQYQQJ6WVLV', + ], }; }; export const makeDefaultUsersRepositoryMockValue = diff --git a/dictation_server/src/features/licenses/test/utility.ts b/dictation_server/src/features/licenses/test/utility.ts new file mode 100644 index 0000000..740896d --- /dev/null +++ b/dictation_server/src/features/licenses/test/utility.ts @@ -0,0 +1,57 @@ +import { DataSource } from 'typeorm'; +import { User } from '../../../repositories/users/entity/user.entity'; +import { Account } from '../../../repositories/accounts/entity/account.entity'; +import { CardLicense } from '../../../repositories/licenses/entity/license.entity'; + +export const createAccount = async ( + datasource: DataSource, +): Promise<{ accountId: number }> => { + const { identifiers } = await datasource.getRepository(Account).insert({ + tier: 1, + country: 'JP', + delegation_permission: false, + locked: false, + company_name: 'test inc.', + verified: true, + deleted_at: '', + created_by: 'test_runner', + created_at: new Date(), + updated_by: 'updater', + updated_at: new Date(), + }); + const account = identifiers.pop() as Account; + return { accountId: account.id }; +}; + +export const createUser = async ( + datasource: DataSource, + accountId: number, + external_id: string, + role: string, + author_id?: string | undefined, +): Promise<{ userId: number; externalId: string }> => { + const { identifiers } = await datasource.getRepository(User).insert({ + account_id: accountId, + external_id: external_id, + role: role, + accepted_terms_version: '1.0', + author_id: author_id, + email_verified: true, + auto_renew: true, + license_alert: true, + notification: true, + created_by: 'test_runner', + created_at: new Date(), + updated_by: 'updater', + updated_at: new Date(), + }); + const user = identifiers.pop() as User; + return { userId: user.id, externalId: external_id }; +}; + +export const selectCardLicensesCount = async ( + datasource: DataSource, +): Promise<{ count: number }> => { + const count = await datasource.getRepository(CardLicense).count(); + return { count: count }; +}; diff --git a/dictation_server/src/repositories/licenses/entity/license.entity.ts b/dictation_server/src/repositories/licenses/entity/license.entity.ts index 8d676ba..77f467d 100644 --- a/dictation_server/src/repositories/licenses/entity/license.entity.ts +++ b/dictation_server/src/repositories/licenses/entity/license.entity.ts @@ -40,7 +40,7 @@ export class License { @PrimaryGeneratedColumn() id: number; - @Column() + @Column({ nullable: true }) expiry_date: Date; @Column() @@ -52,16 +52,16 @@ export class License { @Column() status: string; - @Column() + @Column({ nullable: true }) allocated_user_id: number; - @Column() + @Column({ nullable: true }) order_id: number; - @Column() + @Column({ nullable: true }) deleted_at: Date; - @Column() + @Column({ nullable: true }) delete_order_id: number; } @Entity({ name: 'licenses_history' }) @@ -84,3 +84,27 @@ export class LicenseHistory { @Column() exchange_type: string; } + +@Entity({ name: 'card_license_issue' }) +export class CardLicenseIssue { + @PrimaryGeneratedColumn() + id: number; + + @Column() + issued_at: Date; +} + +@Entity({ name: 'card_licenses' }) +export class CardLicense { + @PrimaryGeneratedColumn() + license_id: number; + + @Column() + issue_id: number; + + @Column() + card_license_key: string; + + @Column({ nullable: true }) + activated_at: Date; +} diff --git a/dictation_server/src/repositories/licenses/licenses.repository.module.ts b/dictation_server/src/repositories/licenses/licenses.repository.module.ts index 1f1e9da..a1ec5bf 100644 --- a/dictation_server/src/repositories/licenses/licenses.repository.module.ts +++ b/dictation_server/src/repositories/licenses/licenses.repository.module.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { LicenseOrder } from './entity/license.entity'; +import { CardLicense, CardLicenseIssue, License, LicenseOrder } from './entity/license.entity'; import { LicensesRepositoryService } from './licenses.repository.service'; @Module({ - imports: [TypeOrmModule.forFeature([LicenseOrder])], + imports: [TypeOrmModule.forFeature([LicenseOrder,License,CardLicense,CardLicenseIssue])], providers: [LicensesRepositoryService], exports: [LicensesRepositoryService], }) diff --git a/dictation_server/src/repositories/licenses/licenses.repository.service.ts b/dictation_server/src/repositories/licenses/licenses.repository.service.ts index b6b6ce0..16e0685 100644 --- a/dictation_server/src/repositories/licenses/licenses.repository.service.ts +++ b/dictation_server/src/repositories/licenses/licenses.repository.service.ts @@ -1,9 +1,17 @@ import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { LicenseOrder } from './entity/license.entity'; +import { DataSource, In } from 'typeorm'; import { + LicenseOrder, + License, + CardLicenseIssue, + CardLicense, +} from './entity/license.entity'; +import { + CARD_LICENSE_LENGTH, + LICENSE_ALLOCATED_STATUS, LICENSE_STATUS_ISSUE_REQUESTING, LICENSE_STATUS_ISSUED, + LICENSE_TYPE, } from '../../constants'; import { PoNumberAlreadyExistError } from './errors/types'; @@ -57,4 +65,124 @@ export class LicensesRepositoryService { ); return createdEntity; } + + /** + * カードライセンスを発行する + * @param accountId + * @param count + * @returns string[] カードライセンスキーの配列 + */ + async createCardLicenses( + accountId: number, + count: number, + ): Promise { + const licenseKeys: string[] = []; + + await this.dataSource.transaction(async (entityManager) => { + const licensesRepo = entityManager.getRepository(License); + const cardLicenseRepo = entityManager.getRepository(CardLicense); + const cardLicenseIssueRepo = + entityManager.getRepository(CardLicenseIssue); + + const licenses = []; + // ライセンステーブルを作成する(BULK INSERT) + for (let i = 0; i < count; i++) { + const license = new License(); + license.account_id = accountId; + license.status = LICENSE_ALLOCATED_STATUS.UNALLOCATED; + license.type = LICENSE_TYPE.CARD; + licenses.push(license); + } + const savedLicenses = await licensesRepo.save(licenses); + + // カードライセンス発行テーブルを作成する + const cardLicenseIssue = new CardLicenseIssue(); + cardLicenseIssue.issued_at = new Date(); + const newCardLicenseIssue = cardLicenseIssueRepo.create(cardLicenseIssue); + const savedCardLicensesIssue = await cardLicenseIssueRepo.save( + newCardLicenseIssue, + ); + + let isDuplicateKeysExist = true; + let generateCount = count; + while (isDuplicateKeysExist) { + const generateKeys = await this.generateLicenseKeys( + generateCount, + licenseKeys, + ); + // licenseKeysが既にカードライセンステーブルに存在するかチェック + const existingCardLicenses = await cardLicenseRepo.find({ + where: { + card_license_key: In(generateKeys), + }, + }); + if (existingCardLicenses.length > 0) { + // 重複分を配列から削除 + existingCardLicenses.forEach((existKey) => { + generateKeys.splice( + generateKeys.indexOf(existKey.card_license_key), + 1, + ); + }); + // 重複がなかったものを格納 + generateKeys.forEach((keys) => { + licenseKeys.push(keys); + }); + // 重複分の再生成を行う + generateCount = existingCardLicenses.length; + continue; + } + // 重複がない場合は本ループで作成したkeyをすべて格納 + generateKeys.forEach((keys) => { + licenseKeys.push(keys); + }); + // 重複がない場合はループを終了 + isDuplicateKeysExist = false; + } + + const cardLicenses = []; + // カードライセンステーブルを作成する(BULK INSERT) + for (let i = 0; i < count; i++) { + const cardLicense = new CardLicense(); + cardLicense.license_id = savedLicenses[i].id; // Licenseテーブルの自動採番されたIDを挿入 + cardLicense.issue_id = savedCardLicensesIssue.id; // CardLicenseIssueテーブルの自動採番されたIDを挿入 + cardLicense.card_license_key = licenseKeys[i]; + cardLicenses.push(cardLicense); + } + await cardLicenseRepo.save(cardLicenses); + }); + return licenseKeys; + } + + /** + * ランダム(大文字英数字)の一意のライセンスキーを作成する。 + * @param count + * @param existingLicenseKeys 既に作成されたライセンスキーの配列 + * @returns licenseKeys + */ + async generateLicenseKeys( + count: number, + existingLicenseKeys: string[], + ): Promise { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const licenseKeys: string[] = []; + + while (licenseKeys.length < count) { + let licenseKey = ''; + for (let i = 0; i < CARD_LICENSE_LENGTH; i++) { + const randomIndex = Math.floor(Math.random() * characters.length); + licenseKey += characters[randomIndex]; + } + + // 重複しない一意のライセンスキーを生成するまで繰り返す + if ( + !licenseKeys.includes(licenseKey) && + !existingLicenseKeys.includes(licenseKey) + ) { + licenseKeys.push(licenseKey); + } + } + + return licenseKeys; + } }