Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

스마트스토어 상품 지원 기능 추가 #243

Merged
merged 8 commits into from
Jan 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
391 changes: 391 additions & 0 deletions backend/package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,24 @@
"@nestjs/swagger": "^7.1.16",
"@nestjs/typeorm": "^10.0.1",
"@songkeys/nestjs-redis": "^10.0.0",
"@types/jsdom": "^21.1.6",
"@types/passport-jwt": "^3.0.13",
"@types/random-useragent": "^0.3.3",
"axios": "^1.6.2",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"firebase-admin": "^11.11.1",
"iconv-lite": "^0.6.3",
"ioredis": "^5.3.2",
"jsdom": "^24.0.0",
"mongoose": "^8.0.1",
"mysql2": "^3.6.3",
"nest-winston": "^1.9.4",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"random-useragent": "^0.5.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0",
Expand Down
7 changes: 7 additions & 0 deletions backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,10 @@ export const TWO_WEEKS_TO_SEC = 2 * 7 * 24 * 60 * 60;
export const TWO_MONTHS_TO_SEC = 61 * 24 * 60 * 60;
export const MAX_TRACKING_PRODUCT_CACHE = parseInt(process.env.MAX_TRACKING_PRODUCT_CACHE || '30');
export const MAX_TARGET_PRICE = 999999999;
export const REGEX_SHOP = {
'11ST': /http[s]?:\/\/(?:www\.|m\.)?11st\.co\.kr\/products\/(?:ma\/|m\/|pa\/)?([1-9]\d*)(?:\?.*)?(?:\/share)?/,
NaverSmartStore:
/http[s]?:\/\/(?:www\.|m\.)?smartstore\.naver\.com\/(?:[a-zA-Z0-9_-]+)\/products\/([a-zA-Z0-9_-]+)/,
NaverBrand: /http[s]?:\/\/(?:www\.|m\.)?brand\.naver\.com\/(?:[a-zA-Z0-9_-]+)\/products\/([a-zA-Z0-9_-]+)/,
} as const;
export const BROWSER_VERSION_20 = 20;
sickbirdd marked this conversation as resolved.
Show resolved Hide resolved
11 changes: 8 additions & 3 deletions backend/src/cron/cron.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { InjectRepository } from '@nestjs/typeorm';
import { ProductInfoDto } from 'src/dto/product.info.dto';
import { TrackingProductRepository } from '../product/trackingProduct.repository';
import { ProductRepository } from '../product/product.repository';
import { getProductInfo11st } from 'src/utils/openapi.11st';
import { InjectModel } from '@nestjs/mongoose';
import { ProductPrice } from 'src/schema/product.schema';
import { Model } from 'mongoose';
Expand All @@ -14,6 +13,7 @@ import { Message } from 'firebase-admin/lib/messaging/messaging-api';
import { TrackingProduct } from 'src/entities/trackingProduct.entity';
import Redis from 'ioredis';
import { InjectRedis } from '@songkeys/nestjs-redis';
import { getProductInfo } from 'src/utils/product.info';

@Injectable()
export class CronService {
Expand All @@ -32,9 +32,14 @@ export class CronService {

@Cron('0 */10 * * * *')
async cyclicPriceChecker() {
const totalProducts = await this.productRepository.find({ select: { id: true, productCode: true } });
const totalProducts = await this.productRepository.find({
select: { id: true, productCode: true, shop: true },
});
const recentProductInfo = await Promise.all(
totalProducts.map(({ productCode, id }) => getProductInfo11st(productCode, id)),
totalProducts.map(async ({ productCode, id, shop }) => {
const productInfo = await getProductInfo(shop, productCode);
return { ...productInfo, id };
}),
sickbirdd marked this conversation as resolved.
Show resolved Hide resolved
);
const productList = recentProductInfo.map((data) => `product:${data.productId}`);
const cacheData = await this.redis.mget(productList); // redis 접근 횟수 줄이기 위한 임시 방편
Expand Down
28 changes: 28 additions & 0 deletions backend/src/dto/product.add.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,31 @@ export class ProductAddDto {
@IsNotEmpty()
targetPrice: number;
}

export class ProductAddDtoV1 {
@ApiProperty({
example: 'SmartStore',
description: '쇼핑몰 정보',
required: true,
})
@IsString()
@IsNotEmpty()
shop: string;
@ApiProperty({
example: '5897533626',
description: '상품 코드',
required: true,
})
@IsString()
@IsNotEmpty()
productCode: string;
@ApiProperty({
example: 36000,
description: '목표 가격',
required: true,
})
@IsNumber()
@Max(MAX_TARGET_PRICE)
@IsNotEmpty()
targetPrice: number;
}
4 changes: 4 additions & 0 deletions backend/src/dto/product.identifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export class ProductIdentifierDto {
productCode: string;
shop: string;
}
5 changes: 4 additions & 1 deletion backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@ import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { GlobalValidationPipe } from './exceptions/validation.pipe';
import { VersioningType } from '@nestjs/common';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new GlobalValidationPipe());
app.enableVersioning({
type: VersioningType.URI,
});
const config = new DocumentBuilder()
.setTitle('PriceGuard')
.setDescription('PriceGuard API 문서')
.setVersion('1.0.0')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);

await app.listen(3000);
}
bootstrap();
98 changes: 85 additions & 13 deletions backend/src/product/product.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import {
UseGuards,
HttpStatus,
UseFilters,
Version,
} from '@nestjs/common';
import { ProductService } from './product.service';
import { ProductUrlDto } from '../dto/product.url.dto';
import { ProductAddDto } from 'src/dto/product.add.dto';
import { ProductAddDto, ProductAddDtoV1 } from 'src/dto/product.add.dto';
sickbirdd marked this conversation as resolved.
Show resolved Hide resolved
import {
ApiBadRequestResponse,
ApiBearerAuth,
Expand Down Expand Up @@ -95,6 +96,22 @@ export class ProductController {
return { statusCode: HttpStatus.OK, message: '상품 추가 성공' };
}

@ApiOperation({
summary: 'SmartStore가 추가된 상품 추가 API',
description: '11번가와 smartStore의 상품을 추가한다',
})
@ApiBody({ type: ProductAddDtoV1 })
@ApiOkResponse({ type: AddProductSuccess, description: '상품 추가 성공' })
@ApiNotFoundResponse({ type: ProductCodeError, description: '상품 추가 실패' })
@ApiBadRequestResponse({ type: RequestError, description: '잘못된 요청입니다.' })
@ApiConflictResponse({ type: AddProductConflict, description: '이미 등록된 상품 존재' })
@Post()
@Version('1')
async addProductV1(@Req() req: Request & { user: User }, @Body() productAddDto: ProductAddDtoV1) {
await this.productService.addProductV1(req.user.id, productAddDto);
return { statusCode: HttpStatus.OK, message: '상품 추가 성공' };
}

@ApiOperation({ summary: '트래킹 상품 목록 조회 API', description: '사용자가 추가한 상품 목록을 조회한다.' })
@ApiOkResponse({ type: GetTrackingListSuccess, description: '상품 목록 조회 성공' })
@ApiNotFoundResponse({ type: ProductNotFound, description: '추가한 상품이 없어서 상품 목록을 조회할 수 없습니다.' })
Expand All @@ -121,21 +138,32 @@ export class ProductController {
@ApiBadRequestResponse({ type: RequestError, description: '잘못된 요청입니다.' })
@Get(':productCode')
async getProductDetails(@Req() req: Request & { user: User }, @Param('productCode') productCode: string) {
const { productName, shop, imageUrl, rank, shopUrl, targetPrice, lowestPrice, price, priceData } =
await this.productService.getProductDetails(req.user.id, productCode);
const productDetails = await this.productService.getProductDetails(req.user.id, productCode);
return {
statusCode: HttpStatus.OK,
message: '상품 URL 검증 성공',
productName,
message: '상품 상세 정보 조회 성공',
productCode,
shop,
imageUrl,
rank,
shopUrl,
targetPrice,
lowestPrice,
price,
priceData,
...productDetails,
};
}

@ApiOperation({ summary: 'SmartStore가 추가된 상품 세부 정보 조회 API', description: '상품 세부 정보를 조회한다.' })
@ApiOkResponse({ type: ProductDetailsSuccess, description: '상품 세부 정보 조회 성공' })
@ApiNotFoundResponse({ type: ProductDetailsNotFound, description: '상품 정보가 존재하지 않습니다.' })
@ApiBadRequestResponse({ type: RequestError, description: '잘못된 요청입니다.' })
@Get(':shop/:productCode')
@Version('1')
async getProductDetailsV1(
@Req() req: Request & { user: User },
@Param('shop') shop: string,
@Param('productCode') productCode: string,
) {
const productDetails = await this.productService.getProductDetailsV1(req.user.id, shop, productCode);
return {
statusCode: HttpStatus.OK,
message: '상품 상세 정보 조회 성공',
productCode,
...productDetails,
};
}

Expand All @@ -149,6 +177,17 @@ export class ProductController {
return { statusCode: HttpStatus.OK, message: '목표 가격 수정 성공' };
}

@ApiOperation({ summary: 'SmartStore가 추가된 상품 목표 가격 수정 API', description: '상품 목표 가격을 수정한다.' })
@ApiOkResponse({ type: UpdateTargetPriceSuccess, description: '상품 목표 가격 수정 성공' })
@ApiNotFoundResponse({ type: TrackingProductsNotFound, description: '추적 상품 찾을 수 없음' })
@ApiBadRequestResponse({ type: RequestError, description: '잘못된 요청입니다.' })
@Patch('/targetPrice')
@Version('1')
async updateTargetPriceV1(@Req() req: Request & { user: User }, @Body() productAddDto: ProductAddDtoV1) {
await this.productService.updateTargetPriceV1(req.user.id, productAddDto);
return { statusCode: HttpStatus.OK, message: '목표 가격 수정 성공' };
}

@ApiOperation({ summary: '추적 상품 삭제 API', description: '추적 상품을 삭제한다.' })
@ApiOkResponse({ type: DeleteProductSuccess, description: '추적 상품 삭제 성공' })
@ApiNotFoundResponse({ type: TrackingProductsNotFound, description: '추적 상품 찾을 수 없음' })
Expand All @@ -159,6 +198,21 @@ export class ProductController {
return { statusCode: HttpStatus.OK, message: '추적 상품 삭제 성공' };
}

@ApiOperation({ summary: 'SmartStore가 추가된 추적 상품 삭제 API', description: '추적 상품을 삭제한다.' })
@ApiOkResponse({ type: DeleteProductSuccess, description: '추적 상품 삭제 성공' })
@ApiNotFoundResponse({ type: TrackingProductsNotFound, description: '추적 상품 찾을 수 없음' })
@ApiBadRequestResponse({ type: RequestError, description: '잘못된 요청입니다.' })
@Delete(':shop/:productCode')
@Version('1')
async deleteProductV1(
@Req() req: Request & { user: User },
@Param('shop') shop: string,
@Param('productCode') productCode: string,
) {
await this.productService.deleteProductV1(req.user.id, shop, productCode);
return { statusCode: HttpStatus.OK, message: '추적 상품 삭제 성공' };
}

@ApiOperation({ summary: '추적 상품 알림 설정 API', description: '추적 상품에 대한 알림을 설정한다.' })
@ApiOkResponse({ type: ToggleAlertSuccess, description: '알림 설정 성공' })
@ApiNotFoundResponse({ type: TrackingProductsNotFound, description: '추적 상품 찾을 수 없음' })
Expand All @@ -168,4 +222,22 @@ export class ProductController {
await this.productService.toggleProductAlert(req.user.id, productCode);
return { statusCode: HttpStatus.OK, message: '알림 설정 성공' };
}

@ApiOperation({
summary: 'SmartStore가 추가된 추적 상품 알림 설정 API',
description: '추적 상품에 대한 알림을 설정한다.',
})
@ApiOkResponse({ type: ToggleAlertSuccess, description: '알림 설정 성공' })
@ApiNotFoundResponse({ type: TrackingProductsNotFound, description: '추적 상품 찾을 수 없음' })
@ApiBadRequestResponse({ type: RequestError, description: '잘못된 요청입니다.' })
@Patch('/alert/:shop/:productCode')
@Version('1')
async toggleAlertV1(
@Req() req: Request & { user: User },
@Param('shop') shop: string,
@Param('productCode') productCode: string,
) {
await this.productService.toggleProductAlertV1(req.user.id, shop, productCode);
return { statusCode: HttpStatus.OK, message: '알림 설정 성공' };
}
}
4 changes: 2 additions & 2 deletions backend/src/product/product.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Product } from 'src/entities/product.entity';
import { createUrl11st } from 'src/utils/openapi.11st';
import { ProductDto } from 'src/dto/product.dto';
import { ProductInfoDto } from 'src/dto/product.info.dto';
import { MAX_TRACKING_RANK } from 'src/constants';
import { TrackingProduct } from 'src/entities/trackingProduct.entity';
import { createUrl } from 'src/utils/product.info';

@Injectable()
export class ProductRepository extends Repository<Product> {
Expand All @@ -19,7 +19,7 @@ export class ProductRepository extends Repository<Product> {

async saveProduct(productDto: ProductDto | ProductInfoDto): Promise<Product> {
const { productName, productCode, shop, imageUrl } = productDto;
const shopUrl = createUrl11st(productCode);
const shopUrl = createUrl(shop, productCode);
const newProduct = Product.create({ productName, productCode, shop, shopUrl, imageUrl });
await newProduct.save();
return newProduct;
Expand Down
Loading