Skip to content

Commit

Permalink
Merge branch 'develop' into 706-서클-별-도서-추천-기능-구현
Browse files Browse the repository at this point in the history
  • Loading branch information
nyj001012 committed Sep 2, 2023
2 parents b0c901b + fd50832 commit 3e7a63e
Show file tree
Hide file tree
Showing 29 changed files with 596 additions and 73 deletions.
1 change: 0 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ WORKDIR /app

FROM pnpm-installed as workspace
COPY ./pnpm-lock.yaml .
COPY patches patches

RUN pnpm fetch

Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"dotenv": "^16.0.0",
"express": "^4.17.2",
"express-rate-limit": "^6.9.0",
"hangul-js": "^0.2.6",
"http-errors": "^2.0.0",
"http-status": "^1.5.0",
"http-terminator": "^3.2.0",
Expand Down
36 changes: 36 additions & 0 deletions backend/src/entity/entities/BookInfoSearchKeywords.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
Column, Entity, Index, JoinColumn, OneToOne, PrimaryGeneratedColumn,
} from 'typeorm';
import { BookInfo } from './BookInfo';

@Index('bookInfoId', ['bookInfoId'], {})
@Entity('book_info_search_keywords')
export class BookInfoSearchKeywords {
@PrimaryGeneratedColumn({ type: 'int', name: 'id' })
id?: number;

@Column('varchar', { name: 'disassembled_title', length: 255 })
disassembledTitle?: string;

@Column('varchar', { name: 'disassembled_author', length: 255 })
disassembledAuthor?: string;

@Column('varchar', { name: 'disassembled_publisher', length: 255 })
disassembledPublisher?: string;

@Column('varchar', { name: 'title_initials', length: 255 })
titleInitials?: string;

@Column('varchar', { name: 'author_initials', length: 255 })
authorInitials?: string;

@Column('varchar', { name: 'publisher_initials', length: 255 })
publisherInitials?: string;

@Column('int', { name: 'book_info_id' })
bookInfoId?: number;

@OneToOne(() => BookInfo, (bookInfo) => bookInfo.id)
@JoinColumn([{ name: 'book_info_id', referencedColumnName: 'id' }])
bookInfo?: BookInfo;
}
1 change: 1 addition & 0 deletions backend/src/entity/entities/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './Book.ts';
export * from './BookInfo.ts';
export * from './BookInfoSearchKeywords.ts';
export * from './Category.ts';
export * from './Lending.ts';
export * from './Likes.ts';
Expand Down
28 changes: 28 additions & 0 deletions backend/src/kysely/paginated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { SelectQueryBuilder } from 'kysely';

type Paginated<O> = {
items: O[],
meta: {
totalItems: number;
totalPages: number;
};
}

export const metaPaginated = async <DB, TB extends keyof DB, O>(
qb: SelectQueryBuilder<DB, TB, O>,
{ page, perPage }: { page: number; perPage: number },
): Promise<Paginated<O>> => {
const { totalItems } = await qb
.clearSelect()
.select(({ fn }) => fn.countAll<number>().as('totalItems'))
.executeTakeFirstOrThrow();

const items = await qb
.offset((page - 1) * perPage)
.limit(perPage)
.execute();

const totalPages = Math.ceil(totalItems / perPage);

return { items, meta: { totalItems, totalPages } };
};
2 changes: 1 addition & 1 deletion backend/src/kysely/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const throwIf = <T>(value: T, ok: (v: T) => boolean) => {
throw new Error(`값이 예상과 달리 ${value}입니다`);
};

export type Visibility = 'public' | 'private' | 'all'
export type Visibility = 'public' | 'hidden' | 'all'
const roles = ['user', 'cadet', 'librarian', 'staff'] as const;
export type Role = typeof roles[number]

Expand Down
3 changes: 2 additions & 1 deletion backend/src/v1/books/books.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ export const searchBookInfo = async (
next: NextFunction,
) => {
// URI에 있는 파라미터/쿼리 변수에 저장
const query = req.query?.query ?? '';
let query = req.query?.query ?? '';
query = query.trim();
const {
page, limit, sort, category,
} = req.query;
Expand Down
114 changes: 88 additions & 26 deletions backend/src/v1/books/books.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,22 @@ import { executeQuery } from '~/mysql';
import * as errorCode from '~/v1/utils/error/errorCode';
import { StringRows } from '~/v1/utils/types';
import { VSearchBookByTag } from '~/entity/entities';
import {
disassembleHangul,
extractHangulInitials,
} from '~/v1/utils/disassembleKeywords';
import * as models from './books.model';
import BooksRepository from './books.repository';
import {
CreateBookInfo, LendingBookList, UpdateBook, UpdateBookInfo,
categoryIds, UpdateBookDonator,
} from './books.type';
import { categoryWithBookCount } from '../DTO/common.interface';
import { Project, RawProject } from '../DTO/cursus.model';
import UsersRepository from '../users/users.repository';
import ErrorResponse from '../utils/error/errorResponse';
import * as searchKeywordsService from '../search-keywords/searchKeywords.service';
import BookInfoSearchKeywordRepository from '../search-keywords/booksInfoSearchKeywords.repository';

const getInfoInNationalLibrary = async (isbn: string) => {
let book;
Expand Down Expand Up @@ -90,6 +99,9 @@ export const search = async (
export const createBook = async (book: CreateBookInfo) => {
const transactionQueryRunner = jipDataSource.createQueryRunner();
const booksRepository = new BooksRepository(transactionQueryRunner);
const bookInfoSearchKeywordRepository = new BookInfoSearchKeywordRepository(
transactionQueryRunner,
);
const isbn = book.isbn === undefined ? '' : book.isbn;
const isbnInBookInfo = await booksRepository.isExistBook(isbn);
const checkNickName = await booksRepository.checkNickName(book.donator);
Expand All @@ -105,6 +117,7 @@ export const createBook = async (book: CreateBookInfo) => {

if (isbnInBookInfo === 0) {
const BookInfo = await booksRepository.createBookInfo(book);
await bookInfoSearchKeywordRepository.createBookInfoSearchKeyword(BookInfo);
if (typeof BookInfo.id === 'number') {
book.infoId = BookInfo.id;
}
Expand Down Expand Up @@ -154,17 +167,52 @@ export const searchInfo = async (
sort: string,
category: string,
) => {
let ordering = '';
const disassemble = query ? disassembleHangul(query) : '';
const initials = query ? extractHangulInitials(query) : '';

let matchScore: string;
let searchCondition: string;
if (!query) {
matchScore = '';
searchCondition = 'TRUE';
} else if (query === initials) {
matchScore = `MATCH(book_info_search_keywords.title_initials,
book_info_search_keywords.author_initials,
book_info_search_keywords.publisher_initials)
AGAINST ('${initials}' IN BOOLEAN MODE)`;
searchCondition = `${matchScore}
OR book_info_search_keywords.title_initials LIKE '%${initials}%'
OR book_info_search_keywords.author_initials LIKE '%${initials}%'
OR book_info_search_keywords.publisher_initials LIKE '%${initials}%'`;
} else {
matchScore = `MATCH(book_info_search_keywords.disassembled_title,
book_info_search_keywords.disassembled_author,
book_info_search_keywords.disassembled_publisher)
AGAINST ('${disassemble}' IN BOOLEAN MODE)`;
searchCondition = `${matchScore}
OR book_info_search_keywords.disassembled_title LIKE '%${disassemble}%'
OR book_info_search_keywords.disassembled_author LIKE '%${disassemble}%'
OR book_info_search_keywords.disassembled_publisher LIKE '%${disassemble}%'`;
}

let ordering: string;
switch (sort) {
case 'title':
ordering = 'ORDER BY book_info.title';
break;
case 'popular':
ordering = 'ORDER BY lendingCnt DESC, book_info.title';
break;
default:
case 'new':
ordering = 'ORDER BY book_info.createdAt DESC, book_info.title';
break;
default:
ordering = matchScore
? `ORDER BY ${matchScore} DESC, book_info.title`
: 'ORDER BY book_info.title';
break;
}

const categoryResult = (await executeQuery(
`
SELECT name
Expand All @@ -175,26 +223,29 @@ export const searchInfo = async (
)) as StringRows[];
const categoryName = categoryResult?.[0]?.name;
const categoryWhere = categoryName ? `category.name = '${categoryName}'` : 'TRUE';
const categoryList = (await executeQuery(
const categoryHaving = categoryName ? `category = '${categoryName}'` : 'TRUE';

const categoryListPromise = executeQuery(
`
SELECT name, count FROM (
SELECT
IFNULL(category.name, "ALL") AS name,
count(category.name) AS count
FROM book_info
RIGHT JOIN category ON book_info.categoryId = category.id
LEFT JOIN book_info_search_keywords
ON book_info.id = book_info_search_keywords.book_info_id
WHERE (
book_info.title LIKE ?
OR book_info.author LIKE ?
OR book_info.isbn LIKE ?
)
book_info.isbn LIKE ?
OR ${searchCondition}
)
GROUP BY category.name WITH ROLLUP) as a
ORDER BY name ASC;
`,
[`%${query}%`, `%${query}%`, `%${query}%`],
)) as models.categoryCount[];
const categoryHaving = categoryName ? `category = '${categoryName}'` : 'TRUE';
const bookList = (await executeQuery(
`,
[`%${query}%`],
) as Promise<models.categoryCount[]>;

const bookListPromise = executeQuery(
`
SELECT
book_info.id AS id,
Expand All @@ -215,35 +266,45 @@ export const searchInfo = async (
SELECT COUNT(id) FROM lending WHERE lending.bookId = book_info.id
) as lendingCnt
FROM book_info
WHERE
(
book_info.title like ?
OR book_info.author like ?
OR book_info.isbn like ?
LEFT JOIN book_info_search_keywords
ON book_info.id = book_info_search_keywords.book_info_id
WHERE (
book_info.isbn LIKE ?
OR ${searchCondition}
)
GROUP BY book_info.id
HAVING ${categoryHaving}
${ordering}
LIMIT ?
OFFSET ?;
`,
[`%${query}%`, `%${query}%`, `%${query}%`, limit, page * limit],
)) as models.BookInfo[];
[`%${query}%`, limit, page * limit],
) as Promise<models.BookInfo[]>;

const totalItems = (await executeQuery(
const totalItemsPromise = executeQuery(
`
SELECT
count(category.name) AS count
FROM book_info
LEFT JOIN category ON book_info.categoryId = category.id
LEFT JOIN book_info_search_keywords
ON book_info.id = book_info_search_keywords.book_info_id
WHERE (
book_info.title LIKE ?
OR book_info.author LIKE ?
OR book_info.isbn LIKE ?
) AND (${categoryWhere})
book_info.isbn LIKE ?
OR ${searchCondition}
) AND (
${categoryWhere}
)
`,
[`%${query}%`, `%${query}%`, `%${query}%`],
))[0].count as number;
[`%${query}%`],
).then((result) => result[0].count) as Promise<number>;

const [categoryList, bookList, totalItems] = await Promise.all([
categoryListPromise,
bookListPromise,
totalItemsPromise,
searchKeywordsService.createSearchKeywordLog(query, disassemble, initials),
]);

const meta = {
totalItems,
Expand All @@ -252,6 +313,7 @@ export const searchInfo = async (
totalPages: Math.ceil(totalItems / limit),
currentPage: page + 1,
};

return { items: bookList, categories: categoryList, meta };
};

Expand Down
2 changes: 2 additions & 0 deletions backend/src/v1/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as bookInfoReviews from './bookInfoReviews.routes';
import * as stock from './stock.routes';
import * as tags from './tags.routes';
import * as cursus from './cursus.routes';
import * as searchKeywords from './searchKeywords.routes';

const router = Router();

Expand All @@ -29,5 +30,6 @@ router.use(bookInfoReviews.path, bookInfoReviews.router);
router.use(stock.path, stock.router);
router.use(tags.path, tags.router);
router.use(cursus.path, cursus.router);
router.use(searchKeywords.path, searchKeywords.router);

export default router;
48 changes: 48 additions & 0 deletions backend/src/v1/routes/searchKeywords.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Router } from 'express';
import { getPopularSearchKeywords } from '../search-keywords/searchKeywords.controller';

export const path = '/search-keywords';
export const router = Router();

router
/**
* @openapi
* /api/search-keywords/popular:
* get:
* description: 인기 검색어 순위를 10위까지 가져온다. 동순위가 있는 경우 가장 최근에 검색된 검색어 순으로 보여준다.
* tags:
* - search-keywords
* responses:
* '200':
* description: 인기 검색어 10위.
* rankingChange는 순위가 올랐으면 양수, 떨어졌으면 음수, 그대로면 0 이며, 새롭게 진입한 검색어는 null이다.
* content:
* application/json:
* schema:
* type: object
* properties:
* items:
* type: array
* example: [
* { "searchKeyword": 'aws', "rankingChange": 2 },
* { "searchKeyword": '파이썬', "rankingChange": 0 },
* { "searchKeyword": '도커', "rankingChange": -2 },
* { "searchKeyword": 'tcp', "rankingChange": 0 },
* { "searchKeyword": '스위프트', "rankingChange": 0 },
* { "searchKeyword": '자바', "rankingChange": 0 },
* { "searchKeyword": '검색', "rankingChange": 0 },
* { "searchKeyword": 'http', "rankingChange": null },
* { "searchKeyword": '리액트', "rankingChange": -1 },
* { "searchKeyword": 'java', "rankingChange": -1 }
* ]
* items:
* type: object
* properties:
* searchKeyword:
* description: 검색어
* type: string
* rankingChange:
* description: 순위 등락
* type: integer
*/
.get('/popular', getPopularSearchKeywords);
Loading

0 comments on commit 3e7a63e

Please sign in to comment.