diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a7d2c547e..6200af0a1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -147,6 +147,8 @@ model JobPosting { positionTitle String @map("position_title") companyName String @map("company_name") locationName String @map("location_name") + industryName String? @map("industry_name") + keyword String? @map("keyword") experienceCode Int @map("experience_code") experienceName String @map("experience_name") requiredEducationCode Int @map("required_education_code") @@ -157,7 +159,7 @@ model JobPosting { expirationTimestamp Int @map("expiration_timestamp") createdAt DateTime @default(now()) @map("created_at") - userSelectedJobs UserSelectedJob[] + userSelectedJobs UserSelectedJob[] @@map("job_posting") } diff --git a/public/assets/character/card/admin-character.png b/public/assets/character/card/admin-character.png new file mode 100644 index 000000000..2f7e71559 Binary files /dev/null and b/public/assets/character/card/admin-character.png differ diff --git a/src/app/(with-nav)/(with-header)/admin/page.tsx b/src/app/(with-nav)/(with-header)/admin/page.tsx new file mode 100644 index 000000000..7391ee3e8 --- /dev/null +++ b/src/app/(with-nav)/(with-header)/admin/page.tsx @@ -0,0 +1,59 @@ +'use client'; + +import AdminButton from '@/features/admin/admin-button'; +import Image from 'next/image'; +import { useState } from 'react'; + +const AdminPage = () => { + const [hovered, setHovered] = useState(false); + + return ( +
+
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + +
+ 레옹병아리 +
+
+
+ +
+

일주일에 한번만 눌러라 마!

+

안 그럼 총 맞는다!

+
+
+ ); +}; + +export default AdminPage; diff --git a/src/app/api/admin/route.ts b/src/app/api/admin/route.ts new file mode 100644 index 000000000..1d628a440 --- /dev/null +++ b/src/app/api/admin/route.ts @@ -0,0 +1,111 @@ +import { ENV } from '@/constants/env-constants'; +import { AUTH_MESSAGE, DB_MESSAGE } from '@/constants/message-constants'; +import { getToken } from 'next-auth/jwt'; +import { NextRequest, NextResponse } from 'next/server'; +import { eduMap, jobMidCdMap, locMcdMap } from '@/features/admin/data/saramin-constants'; +import { prisma } from '@/lib/prisma'; +import { JobPosting } from '@prisma/client'; + +const { + ERROR: { DB_SERVER_ERROR }, + SUCCESS: { CREATE_SUCCESS }, +} = DB_MESSAGE; +const { + ERROR: { EXPIRED_TOKEN }, +} = AUTH_MESSAGE; +const { NEXTAUTH_SECRET, SARAMIN_API_KEY } = ENV; + +const URL = 'https://oapi.saramin.co.kr/job-search'; +const COUNT = 100; +const PAGE = '0'; +const DEFAULT_EDU_CODE = 6; +const LIMIT_EDU_LEVEL = 5; + +type JobRecord = Omit; + +/** + * PATCH 요청 함수 + */ + +export const PATCH = async (request: NextRequest) => { + try { + const token = await getToken({ req: request, secret: NEXTAUTH_SECRET }); + if (!token) return NextResponse.json({ message: EXPIRED_TOKEN }, { status: 401 }); + + const allRecords: JobRecord[] = []; + + for (const [jobCdStr, _] of Object.entries(jobMidCdMap)) { + for (const [_n, locCodes] of Object.entries(locMcdMap)) { + const params = new URLSearchParams({ + 'access-key': SARAMIN_API_KEY as string, + loc_mcd: locCodes, + job_mid_cd: jobCdStr, + start: PAGE, + count: COUNT.toString(), + }); + const res = await fetch(`${URL}?${params}`); + const data = await res.json(); + const jobs = data.jobs.job; + + jobs.forEach((job: any) => { + const pos = job.position || {}; + const rawEd = pos['required-education-level']?.code; + let eduCode: number = DEFAULT_EDU_CODE; + if (rawEd !== undefined) { + const i = Number(rawEd); + eduCode = i <= LIMIT_EDU_LEVEL ? i : eduMap[i]; + } + + allRecords.push({ + url: job.url as string, + companyName: job.company?.detail?.name as string, + positionTitle: pos.title as string, + locationName: pos.location?.name as string, + industryName: pos.industry?.name as string, + keyword: job.keyword as string, + jobMidCodeName: pos['job-mid-code']?.name as string, + experienceCode: Number(pos['experience-level']?.code), + experienceName: pos['experience-level']?.name as string, + requiredEducationCode: eduCode, + requiredEducationName: pos['required-education-level']?.name as string, + openingTimestamp: Number(job['opening-timestamp']), + expirationTimestamp: Number(job['expiration-timestamp']), + }); + }); + + await new Promise((_) => setTimeout(_, 300)); + } + } + + // 기존 jobPosting 데이터 전부 삭제 + await prisma.jobPosting.deleteMany({}); + + // 새 데이터 삽입 + await prisma.jobPosting.createMany({ + data: allRecords.map((record) => ({ + url: record.url, + companyName: record.companyName, + positionTitle: record.positionTitle, + locationName: record.locationName, + industryName: record.industryName, + keyword: record.keyword, + jobMidCodeName: record.jobMidCodeName, + experienceCode: record.experienceCode, + experienceName: record.experienceName, + requiredEducationCode: record.requiredEducationCode, + requiredEducationName: record.requiredEducationName, + openingTimestamp: Number(record.openingTimestamp), + expirationTimestamp: Number(record.expirationTimestamp), + })), + skipDuplicates: true, + }); + + const response = { + message: CREATE_SUCCESS, + }; + + return NextResponse.json({ response }, { status: 200 }); + } catch (error) { + return NextResponse.json({ message: DB_SERVER_ERROR }, { status: 500 }); + } +}; diff --git a/src/constants/env-constants.ts b/src/constants/env-constants.ts index 58e68cd95..5d00e6c02 100644 --- a/src/constants/env-constants.ts +++ b/src/constants/env-constants.ts @@ -18,4 +18,6 @@ export const ENV = { SENTRY_DSN: process.env.SENTRY_DSN, SENTRY_CLIENT_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, + + SARAMIN_API_KEY: process.env.SARAMIN_API_KEY, }; diff --git a/src/constants/message-constants.ts b/src/constants/message-constants.ts index a27edb0fd..3ef601ba9 100644 --- a/src/constants/message-constants.ts +++ b/src/constants/message-constants.ts @@ -100,6 +100,9 @@ export const DB_MESSAGE = { USER_ID_VALIDATION: '유저 아이디가 유효하지 않습니다.', JOB_POSTING_ID_VALIDATION: '채용 공고 아이디가 유효하지 않습니다.', }, + SUCCESS: { + CREATE_SUCCESS: 'DB CREATE SUCCESS', + }, }; export const CHARACTER_MESSAGE = { diff --git a/src/constants/path-constant.ts b/src/constants/path-constant.ts index 6b85f703a..ec8f5c512 100644 --- a/src/constants/path-constant.ts +++ b/src/constants/path-constant.ts @@ -62,6 +62,9 @@ export const ROUTE_HANDLER_PATH = { EXPERIENCE: '/api/character/experience', HISTORY: (id: number) => `/api/character/history/${id}`, }, + ADMIN: { + ROOT: '/api/admin', + }, }; export const QUERY_PARAMS = { diff --git a/src/features/admin/admin-button.tsx b/src/features/admin/admin-button.tsx new file mode 100644 index 000000000..992afee8a --- /dev/null +++ b/src/features/admin/admin-button.tsx @@ -0,0 +1,23 @@ +'use client'; + +import Button from '@/components/ui/button'; +import { postJobPostingDataToDatabase } from '@/features/admin/api/client-services'; +import { showNotiflixConfirm } from '@/utils/show-notiflix-confirm'; + +const AdminButton = () => { + const handleOnClick = () => { + showNotiflixConfirm({ + message: '이번 업데이트 차례가 당신이 맞습니까? 진짜로??', + okFunction: async () => { + await postJobPostingDataToDatabase(); + }, + }); + }; + return ( +
+ +
+ ); +}; + +export default AdminButton; diff --git a/src/features/admin/api/client-services.ts b/src/features/admin/api/client-services.ts new file mode 100644 index 000000000..e0ee373a9 --- /dev/null +++ b/src/features/admin/api/client-services.ts @@ -0,0 +1,19 @@ +import { API_METHOD } from '@/constants/api-method-constants'; +import { ROUTE_HANDLER_PATH } from '@/constants/path-constant'; +import { fetchWithSentry } from '@/utils/fetch-with-sentry'; +import { Notify } from 'notiflix'; + +const { ROOT } = ROUTE_HANDLER_PATH.ADMIN; +const { PATCH } = API_METHOD; + +/** + * + * @description 사람인 데이터를 정제하여 DB로 보내는 로직 + */ +export const postJobPostingDataToDatabase = async (): Promise => { + const { response } = await fetchWithSentry(ROOT, { + method: PATCH, + }); + + Notify.success(response.message); +}; diff --git a/src/features/admin/data/saramin-constants.ts b/src/features/admin/data/saramin-constants.ts new file mode 100644 index 000000000..3c3858121 --- /dev/null +++ b/src/features/admin/data/saramin-constants.ts @@ -0,0 +1,34 @@ +export const jobMidCdMap: Record = { + 16: '기획·전략', + 14: '마케팅·홍보·조사', + 3: '회계·세무·재무', + 5: '인사·노무·HRD', + 4: '총무·법무·사무', + 2: 'IT개발·데이터', + 15: '디자인', + 8: '영업·판매·무역', + 21: '고객상담·TM', + 18: '구매·자재·물류', + 12: '상품기획·MD', + 7: '운전·운송·배송', + 10: '서비스', + 11: '생산', + 22: '건설·건축', + 6: '의료', + 9: '연구·R&D', + 19: '교육', + 13: '미디어·문화·스포츠', + 17: '금융·보험', + 20: '공공·복지', +}; + +export const locMcdMap: Record = { + '수도권': '101000,102000,108000', + '경상도': '110000,111000,104000,106000,107000', + '전라도': '112000,113000,103000', + '충청도': '115000,114000,105000,118000', + '강원도': '109000', + '제주도': '116000', +}; + +export const eduMap: Record = { 6: 1, 7: 2, 8: 3, 9: 4 };