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 };