diff --git a/.env.example b/.env.example index ea107da9..9e2688e9 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,8 @@ CORS_WHITELIST=[CORS 정책에서 허용하는 도메인의 목록(e.g. ["http:/ GOOGLE_APPLICATION_CREDENTIALS=[GOOGLE_APPLICATION_CREDENTIALS JSON] TEST_ACCOUNTS=[스팍스SSO로 로그인시 무조건 테스트로 로그인이 가능한 허용 아이디 목록] SLACK_REPORT_WEBHOOK_URL=[Slack 웹훅 URL들이 담긴 JSON] +NAVER_MAP_API_ID=[네이버 지도 API ID] +NAVER_MAP_API_KEY=[네이버 지도 API KEY] # optional environment variables for taxiSampleGenerator SAMPLE_NUM_OF_ROOMS=[방의 개수] diff --git a/app.js b/app.js index db9773dc..74035415 100644 --- a/app.js +++ b/app.js @@ -69,6 +69,7 @@ app.use("/chats", require("./src/routes/chats")); app.use("/locations", require("./src/routes/locations")); app.use("/reports", require("./src/routes/reports")); app.use("/notifications", require("./src/routes/notifications")); +app.use("/fare", require("./src/routes/fare")); // [Middleware] 전역 에러 핸들러. 에러 핸들러는 router들보다 아래에 등록되어야 합니다. app.use(require("./src/middlewares/errorHandler")); @@ -85,3 +86,6 @@ app.set("io", startSocketServer(serverHttp)); // [Schedule] 스케줄러 시작 require("./src/schedules")(app); + +// [Module] 택시 예상 비용 db 초기화 +require("./src/modules/fare").initializeDatabase(); diff --git a/loadenv.js b/loadenv.js index 438b6f01..cbf47d88 100644 --- a/loadenv.js +++ b/loadenv.js @@ -44,4 +44,8 @@ module.exports = { report: process.env.SLACK_REPORT_WEBHOOK_URL || "", // optional }, eventConfig: process.env.EVENT_CONFIG && JSON.parse(process.env.EVENT_CONFIG), // optional + naverMap: { + apiId: process.env.NAVER_MAP_API_ID, // optional + apiKey: process.env.NAVER_MAP_API_KEY, //optional + }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0317beb7..db4e122d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3666,8 +3666,8 @@ packages: '@types/send': 0.17.1 dev: false - /@types/express@4.17.17: - resolution: {integrity: sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==} + /@types/express@4.17.21: + resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} dependencies: '@types/body-parser': 1.19.2 '@types/express-serve-static-core': 4.17.35 @@ -5997,7 +5997,7 @@ packages: resolution: {integrity: sha512-UUOZ0CVReK1QVU3rbi9bC7N5/le8ziUj0A2ef1Q0M7OPD2KvjEYizptqIxGIo6fSLYDkqBrazILS18tYuRc8gw==} engines: {node: '>=14'} dependencies: - '@types/express': 4.17.17 + '@types/express': 4.17.21 '@types/jsonwebtoken': 9.0.2 debug: 4.3.4 jose: 4.14.4 diff --git a/src/modules/fare.js b/src/modules/fare.js new file mode 100644 index 00000000..b2da72a5 --- /dev/null +++ b/src/modules/fare.js @@ -0,0 +1,208 @@ +const axios = require("axios"); +const logger = require("./logger"); + +const { naverMap } = require("../../loadenv"); +const { taxiFareModel, locationModel } = require("./stores/mongo"); + +const naverMapApi = { + "X-NCP-APIGW-API-KEY-ID": naverMap.apiId, + "X-NCP-APIGW-API-KEY": naverMap.apiKey, +}; + +// 30분 간격으로 하루를 48개의 시간대로 나누어 택시 요금을 계산합니다. +const timeConstants = 48; + +/** + * 출발 시간 (24h를 30분 단위로 분리 & 요일 정보도 하나로 관리, 0 ~ 6 (Sunday~Saturday) * 48 + 0 ~ 47 (0:00 ~ 23:30)) + * @param {Date} time: 시간 + * @returns {number} scaledTime + */ +const scaledTime = (time) => { + return ( + timeConstants * time.getDay() + + time.getHours() * 2 + + (time.getMinutes() >= 30 ? 1 : 0) + ); +}; + +/** + * 데이터베이스를 초기화합니다. 존재하지 않는 필드가 있을때, 기존의 값으로 초기화해 놓거나, 아얘 비어있을 경우에 api를 통해 값을 받아와 초기화합니다. + * @returns + */ +const initializeDatabase = async () => { + try { + if ( + naverMapApi["X-NCP-APIGW-API-KEY"] === undefined || + naverMapApi["X-NCP-APIGW-API-KEY-ID"] === undefined + ) { + logger.error( + "There is no credential for Naver Map. Taxi Fare functions are disabled." + ); + return; + } + const location = await locationModel + .find({ isValid: { $eq: true } }) + .lean(); + + await Promise.all( + location.map(async (from) => { + return Promise.all( + location.map(async (to) => { + if (from._id === to._id) return; + let tableFare = []; + const prevTaxiFare = ( + await taxiFareModel + .findOne( + { + from: from._id, + to: to._id, + }, + { fare: true } + ) + .lean() + ).fare; + const fare = prevTaxiFare + ? prevTaxiFare + : await callTaxiFare(from, to); + if ( + (from.koName === "카이스트 본원" && to.koName === "대전역") || + (from.koName === "대전역" && to.koName === "카이스트 본원") + ) { + [...Array(timeConstants * 7)].map((_, i) => { + tableFare.push({ + updateOne: { + filter: { + from: from._id, + to: to._id, + time: i, + isMajor: true, + }, + update: { + $setOnInsert: { + fare: fare, + }, + }, + upsert: true, + }, + }); + }); + } else { + [...Array(7)].map((_, i) => { + tableFare.push({ + updateOne: { + filter: { + from: from._id, + to: to._id, + time: i * timeConstants, + isMajor: false, + }, + update: { + $setOnInsert: { + fare: fare, + }, + }, + upsert: true, + }, + }); + }); + } + await taxiFareModel.bulkWrite(tableFare); + await new Promise((resolve) => setTimeout(resolve, 200)); + }) + ); + }) + ); + } catch (err) { + logger.error("Error occured while initializing database: " + err.message); + } +}; + +/** + * 주어진 from, to, sTime에 대한 단일 택시 요금을 업데이트합니다. + * @summary 카이스트 본원 <-> 대전역의 경로를 제외한 다른 경로의 경우, cron에 의해 매일 18:00시의 택시 요금을 업데이트 하게 됩니다. + * @summary 카이스트 본원 <-> 대전역의 경우, 미리 캐싱해놓은 데이터를 기반으로 주어진 시간(30분 간격)에 대한 택시 요금을 반환합니다. + * @param {number} sTime - 출발 시간 (scaledTime에 의해 변경된 시간, 0 ~ 6 (Sunday~Saturday) * 48 + 0 ~ 47 (0:00 ~ 23:30)) + * @param {Boolean} isMajor - 카이스트 본원 <-> 대전역 경로 / 이외 경로 + */ +const updateTaxiFare = async (sTime, isMajor) => { + if ( + naverMapApi["X-NCP-APIGW-API-KEY"] === undefined || + naverMapApi["X-NCP-APIGW-API-KEY-ID"] === undefined + ) { + logger.error( + "There is no credential for Naver Map. Taxi Fare functions are disabled." + ); + return; + } + const prevFares = await taxiFareModel + .find({ + time: sTime, + isMajor: isMajor, + }) + .lean(); + await prevFares.reduce(async (acc, item) => { + const from = await locationModel.findOne({ _id: item.from }); + const to = await locationModel.findOne({ _id: item.to }); + + await acc; + await callTaxiFare + .catch((err) => { + logger.error(err.message); + }) + .then(async (fare) => { + if (fare) { + await taxiFareModel.updateOne( + { from: item.from, to: item.to, time: sTime }, + { fare: fare }, + (err, docs) => { + if (err) + logger.error( + "Error occured while updating Taxi Fare document: " + + err.message + ); + } + ); + } + }) + .catch((err) => { + logger.error(err.message); + }); + await new Promise((resolve) => setTimeout(() => resolve, 200)); + return acc; + }, Promise.resolve()); // 초기값 설정 안 하면, 처음에 acc가 undefined로 들어가서 첫 인덱스를 to에서 못 쓰게 됨 +}; + +/** + * @param {locationSchema} from : 출발지 (longitude, latitude) + * @param {locationSchema} to : 도착지 (longitude, latitude) + * @returns naver map api call을 통해 받아온 예상 택시 요금 + */ +const callTaxiFare = async (from, to) => { + if ( + naverMapApi["X-NCP-APIGW-API-KEY"] === undefined || + naverMapApi["X-NCP-APIGW-API-KEY-ID"] === undefined + ) { + logger.error( + "There is no credential for Naver Map. Taxi Fare functions are disabled." + ); + return; + } + return ( + await axios.get( + `${ + "https://naveropenapi.apigw.ntruss.com/map-direction/v1/driving?start=" + + from.longitude + + "," + + from.latitude + }&goal=${to.longitude + "," + to.latitude}&options=traoptimal`, + { headers: naverMapApi } + ) + ).data.route.traoptimal[0].summary.taxiFare; +}; + +module.exports = { + scaledTime, + initializeDatabase, + updateTaxiFare, + callTaxiFare, +}; diff --git a/src/modules/stores/mongo.js b/src/modules/stores/mongo.js index 6bc93246..8f837775 100755 --- a/src/modules/stores/mongo.js +++ b/src/modules/stores/mongo.js @@ -205,6 +205,19 @@ const adminLogSchema = Schema({ }, // 수행 업무 }); +const taxiFareSchema = Schema( + { + from: { type: Schema.Types.ObjectId, ref: "Location", required: true }, // 출발지 + to: { type: Schema.Types.ObjectId, ref: "Location", required: true }, // 도착지 + isMajor: { type: Boolean, default: false }, // 카이스트 본원 <-> 대전역 경로 여부 + time: { type: Number, required: true }, // 출발 시간 (24h를 30분 단위로 분리 & 요일 정보도 하나로 관리, 0 ~ 6 (Sunday~Saturday) * 48 + 0 ~ 47 (0:00 ~ 23:30)) + fare: { type: Number, default: false }, // 예상 택시 요금 + }, + { + timestamps: true, // 최근 업데이트 시간 기록용 + } +); + mongoose.set("strictQuery", true); const database = mongoose.connection; @@ -259,4 +272,5 @@ module.exports = { adminIPWhitelistSchema ), adminLogModel: mongoose.model("AdminLog", adminLogSchema), + taxiFareModel: mongoose.model("TaxiFare", taxiFareSchema), }; diff --git a/src/routes/admin.js b/src/routes/admin.js index 7cd28735..e7748523 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -13,6 +13,7 @@ const { adminLogModel, deviceTokenModel, notificationOptionModel, + taxiFareModel, } = require("../modules/stores/mongo"); const { buildResource } = require("../modules/adminResource"); @@ -36,6 +37,7 @@ const resources = [ adminLogModel, deviceTokenModel, notificationOptionModel, + taxiFareModel, ] .map(buildResource()) .concat(require("../lottery").resources); diff --git a/src/routes/docs/fare.js b/src/routes/docs/fare.js new file mode 100644 index 00000000..62fbfa50 --- /dev/null +++ b/src/routes/docs/fare.js @@ -0,0 +1,54 @@ +const { objectIdPattern } = require("./utils"); + +const tag = "fare"; +const apiPrefix = "/fare"; + +const fareDocs = {}; + +fareDocs[`${apiPrefix}/getTaxiFare`] = { + get: { + tags: [tag], + summary: "예상 택시 요금 반환", + description: + "start, goal, time에 따라 카이스트 본원 <-> 대전역의 경로를 제외한 다른 경로의 경우, 1주일 전 매일 18:00시의 택시 요금을 반환합니다.
카이스트 본원 <-> 대전역의 경우, cron으로 1주일 전 미리 캐싱해놓은 데이터를 기반으로 주어진 시간에 대한 택시 요금을 반환합니다. 만일, 해당 데이터가 존재하지 않을 경우에는 직접 호출해 보여줍니다.", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + from: { type: "string", pattern: objectIdPattern }, + to: { type: "string", pattern: objectIdPattern }, + time: { type: "string", format: "date-time" }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "예상 택시 요금 반환 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + fare: { type: "number", example: 10000 }, + }, + }, + }, + }, + }, + 500: { + description: "fare/getTaxiFareHandler: Failed to load taxi fare", + content: { + "text/html": { + example: "fare/getTaxiFareHandler: Failed to load taxi fare", + }, + }, + }, + }, + }, +}; + +module.exports = fareDocs; diff --git a/src/routes/docs/schemas/fareSchema.js b/src/routes/docs/schemas/fareSchema.js new file mode 100644 index 00000000..0812a86a --- /dev/null +++ b/src/routes/docs/schemas/fareSchema.js @@ -0,0 +1,14 @@ +const { z } = require("zod"); +const { zodToSchemaObject } = require("../utils"); +const { objectId } = require("../../../modules/patterns"); + +const fareZod = { + getTaxiFareHandler: z.object({ + from: z.string().regex(objectId), + to: z.string().regex(objectId), + time: z.string().datetime(), + }), +}; +const fareSchema = zodToSchemaObject(fareZod); + +module.exports = { fareSchema, fareZod }; diff --git a/src/routes/docs/swaggerDocs.js b/src/routes/docs/swaggerDocs.js index b0f51191..e1f1090b 100644 --- a/src/routes/docs/swaggerDocs.js +++ b/src/routes/docs/swaggerDocs.js @@ -1,5 +1,6 @@ const { reportsSchema } = require("./schemas/reportsSchema"); const { roomsSchema } = require("./schemas/roomsSchema"); +const { fareSchema } = require("./schemas/fareSchema"); const { chatsSchema } = require("./schemas/chatsSchema"); const reportsDocs = require("./reports"); const logininfoDocs = require("./logininfo"); @@ -9,6 +10,7 @@ const authReplaceDocs = require("./auth.replace"); const usersDocs = require("./users"); const roomsDocs = require("./rooms"); const chatsDocs = require("./chats"); +const fareDocs = require("./fare"); const { port, nodeEnv } = require("../../../loadenv"); const serverList = [ @@ -69,6 +71,10 @@ const swaggerDocs = { name: "chats", description: "채팅 시 발생하는 이벤트 정리", }, + { + name: "fare", + description: "예상 택시 금액 계산", + }, ], consumes: ["application/json"], produces: ["application/json"], @@ -81,11 +87,13 @@ const swaggerDocs = { ...authReplaceDocs, ...chatsDocs, ...roomsDocs, + ...fareDocs, }, components: { schemas: { ...reportsSchema, ...roomsSchema, + ...fareSchema, ...chatsSchema, }, }, diff --git a/src/routes/fare.js b/src/routes/fare.js new file mode 100644 index 00000000..0cbe37c3 --- /dev/null +++ b/src/routes/fare.js @@ -0,0 +1,15 @@ +const express = require("express"); + +const { validateQuery } = require("../middlewares/zod"); +const { fareZod } = require("./docs/schemas/fareSchema"); +const { getTaxiFareHandler } = require("../services/fare"); + +const router = express.Router(); + +router.get( + "/getTaxiFare", + validateQuery(fareZod.getTaxiFareHandler), + getTaxiFareHandler +); + +module.exports = router; diff --git a/src/schedules/index.js b/src/schedules/index.js index 97818b92..23fe121e 100644 --- a/src/schedules/index.js +++ b/src/schedules/index.js @@ -1,8 +1,14 @@ const cron = require("node-cron"); +const { apiId: naverMapApiId, apiKey: naverMapApiKey } = + require("../../loadenv").naverMap; const registerSchedules = (app) => { cron.schedule("*/5 * * * *", require("./notifyBeforeDepart")(app)); cron.schedule("*/10 * * * *", require("./notifyAfterArrival")(app)); + if (naverMapApiId !== undefined && naverMapApiKey !== undefined) { + cron.schedule("0,30 * * * * ", require("./updateMajorTaxiFare")(app)); + cron.schedule("0 18 * * *", require("./updateMinorTaxiFare")(app)); + } }; module.exports = registerSchedules; diff --git a/src/schedules/updateMajorTaxiFare.js b/src/schedules/updateMajorTaxiFare.js new file mode 100644 index 00000000..2f0afa03 --- /dev/null +++ b/src/schedules/updateMajorTaxiFare.js @@ -0,0 +1,14 @@ +const logger = require("../modules/logger"); + +const { scaledTime, updateTaxiFare } = require("../modules/fare"); + +/* 카이스트 본원<-> 대전역 경로에 대한 택시 요금을 매 30분간격(매시 0분과 30분)으로 1주일 단위 캐싱합니다. */ +module.exports = (app) => async () => { + try { + const time = new Date(); + const sTime = scaledTime(time); + await updateTaxiFare(sTime, true); + } catch (err) { + logger.error(err); + } +}; diff --git a/src/schedules/updateMinorTaxiFare.js b/src/schedules/updateMinorTaxiFare.js new file mode 100644 index 00000000..ef1dd241 --- /dev/null +++ b/src/schedules/updateMinorTaxiFare.js @@ -0,0 +1,13 @@ +const logger = require("../modules/logger"); + +const { updateTaxiFare } = require("../modules/fare"); + +/* 카이스트 본원<-> 대전역 경로 외의 238개 경로에 대한 택시 요금을 매일 18:00시에 1주일 단위로 캐싱합니다. */ +module.exports = (app) => async () => { + try { + const date = new Date(); + await updateTaxiFare(48 * date.getDay(), false); // 18:00시의 택시 요금이지만 db에는 48*(요일) + 0으로 저장됨 + } catch (err) { + logger.error(err); + } +}; diff --git a/src/services/fare.js b/src/services/fare.js new file mode 100644 index 00000000..55d1ad33 --- /dev/null +++ b/src/services/fare.js @@ -0,0 +1,131 @@ +const logger = require("../modules/logger"); + +const { naverMap } = require("../../loadenv"); +const { taxiFareModel, locationModel } = require("../modules/stores/mongo"); +const { scaledTime, callTaxiFare } = require("../modules/fare"); + +const naverMapApi = { + "X-NCP-APIGW-API-KEY-ID": naverMap.apiId, + "X-NCP-APIGW-API-KEY": naverMap.apiKey, +}; + +/** + * 주어진 from, to, time에 대한 택시 요금을 반환합니다. + * @summary 카이스트 본원 <-> 대전역의 경로를 제외한 다른 경로의 경우, 1주일 전 매일 18:00시의 택시 요금을 반환합니다. + * @summary 카이스트 본원 <-> 대전역의 경우, cron으로 1주일 전 미리 캐싱해놓은 데이터를 기반으로 주어진 시간에 대한 택시 요금을 반환합니다. 만일, 해당 데이터가 존재하지 않을 경우에는 직접 호출해 보여줍니다. + * @param {Request} req - 파라미터로 from, to, time을 받습니다. + * - @param {mongoose.Schema.Types.ObjectId} from - 출발지 + * - @param {mongoose.Schema.Types.ObjectId} to - 도착지 + * - @param {Date} time - 출발 시간 (ISO 8601) + */ +const getTaxiFareHandler = async (req, res) => { + try { + if ( + naverMapApi["X-NCP-APIGW-API-KEY"] === false || + naverMapApi["X-NCP-APIGW-API-KEY-ID"] === false + ) { + res.status(503).json({ + error: "fare/getTaxiFareHandler: Naver Map API credential not found", + }); + return; + } + const from = await locationModel + .findOne({ + _id: { $eq: req.query.from }, + }) + .lean(); + const to = await locationModel + .findOne({ _id: { $eq: req.query.to } }) + .lean(); + const sTime = scaledTime(new Date(req.query.time)); + + if (!from || !to) { + res + .status(400) + .json({ error: "fare/getTaxiFareHandler: Wrong location" }); + return; + } + const isMajor = ( + await taxiFareModel + .findOne( + { from: from._id, to: to._id, time: 0 }, + { isMajor: true }, + (err, docs) => { + if (err) + logger.error( + "Error occured while finding Taxi Fare documents: " + + err.message + ); + } + ) + .lean() + ).isMajor; + // 시간대별 정보 관리 (현재: 카이스트 본원 <-> 대전역) + if (isMajor) { + const taxiFare = await taxiFareModel + .findOne( + { + from: from._id, + to: to._id, + time: sTime, + }, + (err, docs) => { + if (err) + logger.error( + "Error occured while finding Taxi Fare documents: " + + err.message + ); + } + ) + .lean(); + //만일 초기화 되지 않은 시간대의 정보를 필요로하는 비상시의 경우 대비 + if (!taxiFare || taxiFare.fare <= 0) { + await callTaxiFare(from, to) + .then((fare) => { + res.status(200).json({ fare: fare }); + }) + .catch((err) => { + logger.error(err.message); + }); + } else { + res.status(200).json({ fare: taxiFare.fare }); + } + } else { + const taxiFare = await taxiFareModel + .findOne( + { + from: from._id, + to: to._id, + time: 0, + }, + (err, docs) => { + if (err) + logger.error( + "Error occured while finding Taxi Fare documents: " + + err.message + ); + } + ) + .lean(); + //만일 초기화 되지 않은 시간대의 정보를 필요로하는 비상시의 경우 대비 + if (!taxiFare || taxiFare.fare <= 0) { + await callTaxiFare(from, to) + .then((fare) => { + res.status(200).json({ fare: fare }); + }) + .catch((err) => { + logger.error(err.message); + }); + } else { + res.status(200).json({ fare: taxiFare.fare }); + } + } + } catch (err) { + logger.error(err.message); + res + .status(500) + .json({ error: "fare/getTaxiFareHandler: Failed to load Taxi Fare" }); + } +}; + +module.exports = { getTaxiFareHandler };