diff --git a/app.js b/app.js index d8a1f0f3..3394dec0 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,7 @@ // 모듈 require const express = require("express"); const http = require("http"); -const { port: httpPort, eventMode } = require("./loadenv"); +const { port: httpPort, eventConfig } = require("./loadenv"); const logger = require("./src/modules/logger"); const { connectDatabase } = require("./src/modules/stores/mongo"); const { startSocketServer } = require("./src/modules/socket"); @@ -19,6 +19,9 @@ connectDatabase(); app.use(express.urlencoded({ extended: false })); app.use(express.json()); +// reverse proxy가 설정한 헤더를 신뢰합니다. +app.set("trust proxy", true); + // [Middleware] CORS 설정 app.use(require("./src/middlewares/cors")); @@ -43,8 +46,11 @@ app.use(require("./src/middlewares/limitRate")); app.use("/docs", require("./src/routes/docs")); // 2023 추석 이벤트 전용 라우터입니다. -eventMode && - app.use(`/events/${eventMode}`, require("./src/lottery").lotteryRouter); +eventConfig && + app.use( + `/events/${eventConfig.mode}`, + require("./src/lottery").lotteryRouter + ); // [Middleware] 모든 API 요청에 대하여 origin 검증 app.use(require("./src/middlewares/originValidator")); diff --git a/loadenv.js b/loadenv.js index 2bce4fb9..3dfc5be6 100644 --- a/loadenv.js +++ b/loadenv.js @@ -38,5 +38,10 @@ module.exports = { slackWebhookUrl: { report: process.env.SLACK_REPORT_WEBHOOK_URL || "", // optional }, - eventMode: undefined, + eventConfig: (process.env.EVENT_CONFIG && + JSON.parse(process.env.EVENT_CONFIG)) || { + mode: "2023fall", + startAt: "2023-09-25T00:00:00+09:00", + endAt: "2023-10-10T00:00:00+09:00", + }, }; diff --git a/src/lottery/index.js b/src/lottery/index.js index 7ca282fe..9fc6f2c4 100644 --- a/src/lottery/index.js +++ b/src/lottery/index.js @@ -6,11 +6,16 @@ const { transactionModel, } = require("./modules/stores/mongo"); -const { eventMode } = require("../../loadenv"); const { buildResource } = require("../modules/adminResource"); +const { + addOneItemStockAction, + addFiveItemStockAction, +} = require("./modules/items"); + +const { eventConfig } = require("../../loadenv"); // [Routes] 기존 docs 라우터의 docs extend -require("./routes/docs")(); +eventConfig && require("./routes/docs")(); const lotteryRouter = express.Router(); @@ -22,18 +27,21 @@ lotteryRouter.use("/global-state", require("./routes/globalState")); lotteryRouter.use("/transactions", require("./routes/transactions")); lotteryRouter.use("/items", require("./routes/items")); lotteryRouter.use("/public-notice", require("./routes/publicNotice")); +lotteryRouter.use("/quests", require("./routes/quests")); -const resources = [ - eventStatusModel, - questModel, - itemModel, - transactionModel, -].map(buildResource()); +const itemResource = buildResource([ + addOneItemStockAction, + addFiveItemStockAction, +])(itemModel); +const otherResources = [eventStatusModel, questModel, transactionModel].map( + buildResource() +); -const contracts = eventMode && require(`./modules/contracts/${eventMode}`); +const contracts = + eventConfig && require(`./modules/contracts/${eventConfig.mode}`); module.exports = { lotteryRouter, - resources, + resources: [itemResource, ...otherResources], contracts, }; diff --git a/src/lottery/middlewares/timestampValidator.js b/src/lottery/middlewares/timestampValidator.js new file mode 100644 index 00000000..22511536 --- /dev/null +++ b/src/lottery/middlewares/timestampValidator.js @@ -0,0 +1,19 @@ +const { eventConfig } = require("../../../loadenv"); +const eventPeriod = eventConfig && { + startAt: new Date(eventConfig.startAt), + endAt: new Date(eventConfig.endAt), +}; + +const timestampValidator = (req, res, next) => { + if ( + !eventPeriod || + req.timestamp >= eventPeriod.endAt || + req.timestamp < eventPeriod.startAt + ) { + return res.status(400).json({ error: "out of date" }); + } else { + next(); + } +}; + +module.exports = timestampValidator; diff --git a/src/lottery/modules/contracts/2023fall.js b/src/lottery/modules/contracts/2023fall.js index abe2f0d8..a896a6ce 100644 --- a/src/lottery/modules/contracts/2023fall.js +++ b/src/lottery/modules/contracts/2023fall.js @@ -4,83 +4,103 @@ const mongoose = require("mongoose"); /** 전체 퀘스트 목록입니다. */ const quests = buildQuests({ firstLogin: { - name: "이벤트 기간 첫 로그인", - description: "", - imageUrl: "", + name: "첫 발걸음", + description: + "로그인만 해도 송편을 얻을 수 있다고?? 이벤트 기간에 처음으로 SPARCS Taxi 서비스에 로그인하여 송편을 받아보세요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_firstLogin.png", reward: { ticket1: 1, }, }, payingAndSending: { - name: "2명 이상 탑승한 방에서 정산/송금 완료", - description: "", - imageUrl: "", + name: "함께하는 택시의 여정", + description: + "2명 이상과 함께 택시를 타고 정산/송금까지 완료해보세요. 최대 3번까지 송편을 받을 수 있어요. 정산/송금 버튼은 채팅 페이지 좌측 하단의 +버튼을 눌러 확인할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_payingAndSending.png", reward: 300, maxCount: 3, }, firstRoomCreation: { name: "첫 방 개설", - description: "", - imageUrl: "", + description: + "원하는 택시팟을 찾을 수 없다면? 원하는 조건으로 방 개설 페이지에서 방을 직접 개설해보세요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_firstRoomCreation.png", reward: 50, }, roomSharing: { - name: "방 공유하기", - description: "", - imageUrl: "", + name: "Taxi로 모여라", + description: + "방을 공유해 친구들을 택시에 초대해보세요. 채팅창 상단의 햄버거(☰) 버튼을 누르면 공유하기 버튼을 찾을 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_roomSharing.png", reward: 50, + isApiRequired: true, }, paying: { - name: "2명 이상 탑승한 방에서 정산하기", - description: "", - imageUrl: "", + name: "정산해요 택시의 숲", + description: + "2명 이상과 함께 택시를 타고 택시비를 결제한 후 정산하기를 요청해보세요. 정산하기 버튼은 채팅 페이지 좌측 하단의 +버튼을 눌러 확인할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_paying.png", reward: 100, maxCount: 3, }, sending: { - name: "2명 이상 탑승한 방에서 송금하기", - description: "", - imageUrl: "", + name: "송금 완료! 친구야 고마워", + description: + "2명 이상과 함께 택시를 타고 택시비를 결제한 분께 송금해주세요. 송금하기 버튼은 채팅 페이지 좌측 하단의 +버튼을 눌러 확인할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_sending.png", reward: 50, maxCount: 3, }, nicknameChanging: { - name: "닉네임 변경", - description: "", - imageUrl: "", + name: "닉네임 변신", + description: + "닉네임을 변경하여 자신을 표현하세요. 마이페이지수정하기 버튼을 눌러 닉네임을 수정할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_nicknameChanging.png", reward: 50, }, accountChanging: { - name: "계좌 등록 또는 변경", - description: "", - imageUrl: "", + name: "계좌 등록은 정산의 시작", + description: + "정산하기 기능을 더욱 빠르고 이용할 수 있다고? 계좌번호를 등록하면 정산하기를 할 때 계좌가 자동으로 입력돼요. 마이페이지수정하기 버튼을 눌러 계좌번호를 등록 또는 수정할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_accountChanging.png", reward: 50, }, adPushAgreement: { - name: "광고성 푸시 알림 수신 동의", - description: "", - imageUrl: "", + name: "Taxi의 소울메이트", + description: + "Taxi 서비스를 잊지 않도록 가끔 찾아갈게요! 광고성 푸시 알림 수신 동의를 해주시면 방이 많이 모이는 시즌, 주변에 택시앱 사용자가 있을 때 알려드릴 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_adPushAgreement.png", reward: 50, }, eventSharingOnInstagram: { - name: "이벤트 인스타그램 스토리에 공유", - description: "", - imageUrl: "", + name: "나만 알기에는 아까운 이벤트", + description: + "추석에 맞춰 쏟아지는 혜택들. 나만 알 순 없죠. 인스타그램 친구들에게 스토리로 공유해보아요. 이벤트 안내 페이지에서 인스타그램 스토리에 공유하기을 눌러보세요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_eventSharingOnInstagram.png", reward: 100, + isApiRequired: true, }, purchaseSharingOnInstagram: { - name: "아이템 구매 후 인스타그램 스토리에 공유", - description: "", - imageUrl: "", + name: "상품 획득을 축하합니다", + description: + "이벤트를 열심히 즐긴 당신. 그 상품 획득을 축하 받을 자격이 충분합니다. 달토끼 상점에서 상품 구매 후 뜨는 인스타그램 스토리에 공유하기 버튼을 눌러 상품 획득을 공유하세요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_purchaseSharingOnInstagram.png", reward: 100, + isApiRequired: true, }, }); -const eventPeriod = { - start: new Date("2023-09-25T00:00:00+09:00"), // Inclusive - end: new Date("2023-10-10T00:00:00+09:00"), // Exclusive -}; - /** * firstLogin 퀘스트의 완료를 요청합니다. * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. @@ -89,34 +109,23 @@ const eventPeriod = { * @usage lottery/globalState/createUserGlobalStateHandler */ const completeFirstLoginQuest = async (userId, timestamp) => { - return await completeQuest(userId, timestamp, eventPeriod, quests.firstLogin); + return await completeQuest(userId, timestamp, quests.firstLogin); }; /** - * payingAndSending 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이거나, 모든 참가자가 정산 또는 송금을 완료하지 않았다면 요청하지 않습니다. + * payingAndSending 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이면 요청하지 않습니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @param {Object} roomObject - 방의 정보입니다. * @param {Array<{ user: mongoose.Types.ObjectId }>} roomObject.part - 참여자 목록입니다. - * @param {number} roomObject.settlementTotal - 정산 또는 송금이 완료된 참여자 수입니다. * @returns {Promise} * @description 정산 요청 또는 송금이 이루어질 때마다 호출해 주세요. - * @usage rooms/commitPaymentHandler, rooms/settlementHandler + * @usage rooms - commitPaymentHandler, rooms - settlementHandler */ -const completePayingAndSendingQuest = async (timestamp, roomObject) => { +const completePayingAndSendingQuest = async (userId, timestamp, roomObject) => { if (roomObject.part.length < 2) return null; - if (roomObject.part.length > roomObject.settlementTotal) return null; - return await Promise.all( - roomObject.part.map( - async (participant) => - await completeQuest( - participant.user._id, - timestamp, - eventPeriod, - quests.payingAndSending - ) - ) - ); + return await completeQuest(userId, timestamp, quests.payingAndSending); }; /** @@ -125,19 +134,10 @@ const completePayingAndSendingQuest = async (timestamp, roomObject) => { * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @returns {Promise} * @description 방을 만들 때마다 호출해 주세요. - * @usage rooms/createHandler + * @usage rooms - createHandler */ const completeFirstRoomCreationQuest = async (userId, timestamp) => { - return await completeQuest( - userId, - timestamp, - eventPeriod, - quests.firstRoomCreation - ); -}; - -const completeRoomSharingQuest = async () => { - // TODO + return await completeQuest(userId, timestamp, quests.firstRoomCreation); }; /** @@ -148,12 +148,12 @@ const completeRoomSharingQuest = async () => { * @param {Array<{ user: mongoose.Types.ObjectId }>} roomObject.part - 참여자 목록입니다. * @returns {Promise} * @description 정산 요청이 이루어질 때마다 호출해 주세요. - * @usage rooms/commitPaymentHandler + * @usage rooms - commitPaymentHandler */ const completePayingQuest = async (userId, timestamp, roomObject) => { if (roomObject.part.length < 2) return null; - return await completeQuest(userId, timestamp, eventPeriod, quests.paying); + return await completeQuest(userId, timestamp, quests.paying); }; /** @@ -164,12 +164,12 @@ const completePayingQuest = async (userId, timestamp, roomObject) => { * @param {Array<{ user: mongoose.Types.ObjectId }>} roomObject.part - 참여자 목록입니다. * @returns {Promise} * @description 송금이 이루어질 때마다 호출해 주세요. - * @usage rooms/settlementHandler + * @usage rooms - settlementHandler */ const completeSendingQuest = async (userId, timestamp, roomObject) => { if (roomObject.part.length < 2) return null; - return await completeQuest(userId, timestamp, eventPeriod, quests.sending); + return await completeQuest(userId, timestamp, quests.sending); }; /** @@ -178,15 +178,10 @@ const completeSendingQuest = async (userId, timestamp, roomObject) => { * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @returns {Promise} * @description 닉네임을 변경할 때마다 호출해 주세요. - * @usage users/editNicknameHandler + * @usage users - editNicknameHandler */ const completeNicknameChangingQuest = async (userId, timestamp) => { - return await completeQuest( - userId, - timestamp, - eventPeriod, - quests.nicknameChanging - ); + return await completeQuest(userId, timestamp, quests.nicknameChanging); }; /** @@ -196,17 +191,12 @@ const completeNicknameChangingQuest = async (userId, timestamp) => { * @param {string} newAccount - 변경된 계좌입니다. * @returns {Promise} * @description 계좌를 변경할 때마다 호출해 주세요. - * @usage users/editAccountHandler + * @usage users - editAccountHandler */ const completeAccountChangingQuest = async (userId, timestamp, newAccount) => { if (newAccount === "") return null; - return await completeQuest( - userId, - timestamp, - eventPeriod, - quests.accountChanging - ); + return await completeQuest(userId, timestamp, quests.accountChanging); }; /** @@ -225,34 +215,17 @@ const completeAdPushAgreementQuest = async ( ) => { if (!advertisement) return null; - return await completeQuest( - userId, - timestamp, - eventPeriod, - quests.adPushAgreement - ); -}; - -const completeEventSharingOnInstagramQuest = async () => { - // TODO -}; - -const completePurchaseSharingOnInstagramQuest = async () => { - // TODO + return await completeQuest(userId, timestamp, quests.adPushAgreement); }; module.exports = { quests, - eventPeriod, completeFirstLoginQuest, completePayingAndSendingQuest, completeFirstRoomCreationQuest, - completeRoomSharingQuest, completePayingQuest, completeSendingQuest, completeNicknameChangingQuest, completeAccountChangingQuest, completeAdPushAgreementQuest, - completeEventSharingOnInstagramQuest, - completePurchaseSharingOnInstagramQuest, }; diff --git a/src/lottery/modules/items.js b/src/lottery/modules/items.js new file mode 100644 index 00000000..dae8e906 --- /dev/null +++ b/src/lottery/modules/items.js @@ -0,0 +1,66 @@ +const { itemModel } = require("./stores/mongo"); +const { buildRecordAction } = require("../../modules/adminResource"); +const logger = require("../../modules/logger"); + +const addItemStockActionHandler = (count) => async (req, res, context) => { + const itemId = context.record.params._id; + const oldStock = context.record.params.stock; + + try { + const item = await itemModel + .findOneAndUpdate( + { _id: itemId }, + { + $inc: { + stock: count, + }, + }, + { + new: true, + } + ) + .lean(); + if (!item) throw new Error("Fail to update stock"); + + let record = context.record.toJSON(context.currentAdmin); + record.params = item; + + return { + record, + notice: { + message: `성공적으로 재고 ${count}개를 추가했습니다. (${oldStock} → ${item.stock})`, + }, + response: {}, + }; + } catch (err) { + logger.error(err); + logger.error( + `Fail to process addItemStockActionHandler(${count}) for Item ${itemId}` + ); + + return { + record: context.record.toJSON(context.currentAdmin), + notice: { + message: `재고를 추가하지 못했습니다. 오류 메세지: ${err}`, + type: "error", + }, + }; + } +}; +const addItemStockActionLogs = ["update"]; + +const addOneItemStockAction = buildRecordAction( + "addOneItemStock", + addItemStockActionHandler(1), + addItemStockActionLogs +); +const addFiveItemStockAction = buildRecordAction( + "addFiveItemStock", + addItemStockActionHandler(5), + addItemStockActionLogs +); + +module.exports = { + addOneItemStockAction, + addFiveItemStockAction, +}; diff --git a/src/lottery/modules/populates/transactions.js b/src/lottery/modules/populates/transactions.js index 9f3a958e..6d965258 100644 --- a/src/lottery/modules/populates/transactions.js +++ b/src/lottery/modules/populates/transactions.js @@ -1,10 +1,23 @@ const transactionPopulateOption = [ { path: "item", - select: "name imageUrl price description isDisabled stock itemType", + select: + "name imageUrl instagramStoryStickerImageUrl price description isDisabled stock itemType", + }, +]; + +const publicNoticePopulateOption = [ + { + path: "userId", + select: "nickname", + }, + { + path: "item", + select: "name price description", }, ]; module.exports = { transactionPopulateOption, + publicNoticePopulateOption, }; diff --git a/src/lottery/modules/quests.js b/src/lottery/modules/quests.js index ce2d22d4..539bfc41 100644 --- a/src/lottery/modules/quests.js +++ b/src/lottery/modules/quests.js @@ -7,6 +7,12 @@ const { const logger = require("../../modules/logger"); const mongoose = require("mongoose"); +const { eventConfig } = require("../../../loadenv"); +const eventPeriod = eventConfig && { + startAt: new Date(eventConfig.startAt), + endAt: new Date(eventConfig.endAt), +}; + const requiredQuestFields = ["name", "description", "imageUrl", "reward"]; const buildQuests = (quests) => { for (const [id, quest] of Object.entries(quests)) { @@ -31,11 +37,14 @@ const buildQuests = (quests) => { } // quest.reward에 누락된 필드가 있는 경우, 기본값(0)으로 설정합니다. - quest.reward.credit = quest.reward.credit || 0; - quest.reward.ticket1 = quest.reward.ticket1 || 0; + quest.reward.credit = quest.reward.credit ?? 0; + quest.reward.ticket1 = quest.reward.ticket1 ?? 0; // quest.maxCount가 없는 경우, 기본값(1)으로 설정합니다. - quest.maxCount = quest.maxCount || 1; + quest.maxCount = quest.maxCount ?? 1; + + // quest.isApiRequired가 없는 경우, 기본값(false)으로 설정합니다. + quest.isApiRequired = quest.isApiRequired ?? false; } return quests; @@ -45,9 +54,6 @@ const buildQuests = (quests) => { * 퀘스트 완료를 요청합니다. * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. - * @param {Object} eventPeriod - 이벤트의 기간입니다. - * @param {Date} eventPeriod.start - 이벤트의 시작 시각(Inclusive)입니다. - * @param {Date} eventPeriod.end - 이벤트의 종료 시각(Exclusive)입니다. * @param {Object} quest - 퀘스트의 정보입니다. * @param {string} quest.id - 퀘스트의 Id입니다. * @param {string} quest.name - 퀘스트의 이름입니다. @@ -57,14 +63,14 @@ const buildQuests = (quests) => { * @param {number} quest.maxCount - 퀘스트의 최대 완료 가능 횟수입니다. * @returns {Object|null} 성공한 경우 Object를, 실패한 경우 null을 반환합니다. 이미 최대 완료 횟수에 도달했거나, 퀘스트가 원격으로 비활성화 된 경우에도 실패로 처리됩니다. */ -const completeQuest = async (userId, timestamp, eventPeriod, quest) => { +const completeQuest = async (userId, timestamp, quest) => { try { // 1단계: 유저의 EventStatus를 가져옵니다. const eventStatus = await eventStatusModel.findOne({ userId }).lean(); if (!eventStatus) return null; // 2단계: 이벤트 기간인지 확인합니다. - if (timestamp >= eventPeriod.end || timestamp < eventPeriod.start) { + if (timestamp >= eventPeriod.endAt || timestamp < eventPeriod.startAt) { logger.info( `User ${userId} failed to complete auto-disabled ${quest.id}Quest` ); @@ -95,7 +101,8 @@ const completeQuest = async (userId, timestamp, eventPeriod, quest) => { // 5단계: 완료 보상 중 티켓이 있는 경우, 티켓 정보를 가져옵니다. const ticket1 = quest.reward.ticket1 && (await itemModel.findOne({ itemType: 1 }).lean()); - if (quest.reward.ticket1 && !ticket1) throw "Fail to find ticket1"; + if (quest.reward.ticket1 && !ticket1) + throw new Error("Fail to find ticket1"); // 6단계: 유저의 EventStatus를 업데이트합니다. await eventStatusModel.updateOne( diff --git a/src/lottery/modules/stores/mongo.js b/src/lottery/modules/stores/mongo.js index 296db681..9db6905d 100644 --- a/src/lottery/modules/stores/mongo.js +++ b/src/lottery/modules/stores/mongo.js @@ -57,6 +57,9 @@ const itemSchema = Schema({ type: String, required: true, }, + instagramStoryStickerImageUrl: { + type: String, + }, price: { type: Number, required: true, @@ -118,6 +121,10 @@ const transactionSchema = Schema({ type: Schema.Types.ObjectId, ref: "Item", }, + itemType: { + type: Number, + enum: [0, 1, 2, 3], + }, comment: { type: String, required: true, diff --git a/src/lottery/routes/docs/globalState.js b/src/lottery/routes/docs/globalState.js index 6883ac33..077f7628 100644 --- a/src/lottery/routes/docs/globalState.js +++ b/src/lottery/routes/docs/globalState.js @@ -1,5 +1,5 @@ -const { eventMode } = require("../../../../loadenv"); -const apiPrefix = `/events/${eventMode}/global-state`; +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig.mode}/global-state`; const globalStateDocs = {}; globalStateDocs[`${apiPrefix}/`] = { @@ -16,7 +16,7 @@ globalStateDocs[`${apiPrefix}/`] = { schema: { type: "object", required: [ - "isAgree", + "isAgreeOnTermsOfEvent", "creditAmount", "completedQuests", "ticket1Amount", @@ -66,6 +66,7 @@ globalStateDocs[`${apiPrefix}/`] = { "imageUrl", "reward", "maxCount", + "isApiRequired", ], properties: { id: { @@ -111,6 +112,11 @@ globalStateDocs[`${apiPrefix}/`] = { description: "최대 완료 가능 횟수", example: 1, }, + isApiRequired: { + type: "boolean", + description: `/events/${eventConfig.mode}/quests/complete/:questId API를 통해 퀘스트 완료를 요청할 수 있는지 여부`, + example: false, + }, }, }, }, @@ -123,11 +129,21 @@ globalStateDocs[`${apiPrefix}/`] = { }, }; globalStateDocs[`${apiPrefix}/create`] = { - get: { + post: { tags: [`${apiPrefix}`], summary: "Frontend에서 Global state로 관리하는 정보 생성", description: "유저의 재화 개수, 퀘스트 완료 상태 등 Frontend에서 Global state로 관리할 정보를 생성합니다.", + requestBody: { + description: "", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/createUserGlobalStateHandler", + }, + }, + }, + }, responses: { 200: { description: "", diff --git a/src/lottery/routes/docs/globalStateSchema.js b/src/lottery/routes/docs/globalStateSchema.js new file mode 100644 index 00000000..7a9a5260 --- /dev/null +++ b/src/lottery/routes/docs/globalStateSchema.js @@ -0,0 +1,15 @@ +const globalStateSchema = { + createUserGlobalStateHandler: { + type: "object", + required: ["phoneNumber"], + properties: { + phoneNumber: { + type: "string", + pattern: "^010-?([0-9]{3,4})-?([0-9]{4})$", + }, + }, + errorMessage: "validation: bad request", + }, +}; + +module.exports = globalStateSchema; diff --git a/src/lottery/routes/docs/items.js b/src/lottery/routes/docs/items.js index d1c0956e..39bc9111 100644 --- a/src/lottery/routes/docs/items.js +++ b/src/lottery/routes/docs/items.js @@ -1,5 +1,5 @@ -const { eventMode } = require("../../../../loadenv"); -const apiPrefix = `/events/${eventMode}/items`; +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig.mode}/items`; const itemsDocs = {}; itemsDocs[`${apiPrefix}/list`] = { @@ -37,6 +37,16 @@ itemsDocs[`${apiPrefix}/purchase/:itemId`] = { tags: [`${apiPrefix}`], summary: "상품 구매", description: "상품을 구매합니다.", + requestBody: { + description: "", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/purchaseHandler", + }, + }, + }, + }, responses: { 200: { description: "", diff --git a/src/lottery/routes/docs/itemsSchema.js b/src/lottery/routes/docs/itemsSchema.js index 1601f237..2549540b 100644 --- a/src/lottery/routes/docs/itemsSchema.js +++ b/src/lottery/routes/docs/itemsSchema.js @@ -26,6 +26,11 @@ const itemBase = { description: "이미지 썸네일 URL", example: "THUMBNAIL URL", }, + instagramStoryStickerImageUrl: { + type: "string", + description: "인스타그램 스토리 스티커 이미지 URL", + example: "STICKER URL", + }, price: { type: "number", description: "상품의 가격. 0 이상입니다.", @@ -43,8 +48,8 @@ const itemBase = { }, stock: { type: "number", - description: "남은 상품 재고. 0 이상입니다.", - example: 10, + description: "남은 상품 재고. 재고가 있는 경우 1, 없는 경우 0입니다.", + example: 1, }, }, }; diff --git a/src/lottery/routes/docs/publicNotice.js b/src/lottery/routes/docs/publicNotice.js index cda93fa1..e5ce2f55 100644 --- a/src/lottery/routes/docs/publicNotice.js +++ b/src/lottery/routes/docs/publicNotice.js @@ -1,7 +1,38 @@ -const { eventMode } = require("../../../../loadenv"); -const apiPrefix = `/events/${eventMode}/public-notice`; +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig.mode}/public-notice`; const publicNoticeDocs = {}; +publicNoticeDocs[`${apiPrefix}/recentTransactions`] = { + get: { + tags: [`${apiPrefix}`], + summary: "최근의 유의미한 상품 획득 기록 반환", + description: "모든 유저의 상품 획득 내역 중 유의미한 기록을 가져옵니다.", + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["transactions"], + properties: { + transactions: { + type: "array", + description: "상품 획득 기록의 배열", + items: { + type: "string", + example: + "tu**************님께서 일반응모권을(를) 획득하셨습니다.", + }, + }, + }, + }, + }, + }, + }, + }, + }, +}; publicNoticeDocs[`${apiPrefix}/leaderboard`] = { get: { tags: [`${apiPrefix}`], @@ -15,7 +46,12 @@ publicNoticeDocs[`${apiPrefix}/leaderboard`] = { "application/json": { schema: { type: "object", - required: ["leaderboard"], + required: [ + "leaderboard", + "totalTicket1Amount", + "totalTicket2Amount", + "totalUserAmount", + ], properties: { leaderboard: { type: "array", @@ -28,6 +64,7 @@ publicNoticeDocs[`${apiPrefix}/leaderboard`] = { "ticket1Amount", "ticket2Amount", "probability", + "probabilityV2", ], properties: { nickname: { @@ -55,9 +92,29 @@ publicNoticeDocs[`${apiPrefix}/leaderboard`] = { description: "1등 당첨 확률", example: 0.001, }, + probabilityV2: { + type: "number", + description: "근사적인 상품 당첨 확률", + example: 0.015, + }, }, }, }, + totalTicket1Amount: { + type: "number", + description: "전체 일반 티켓의 수", + example: 300, + }, + totalTicket2Amount: { + type: "number", + description: "전체 고급 티켓의 수", + example: 100, + }, + totalUserAmount: { + type: "number", + description: "리더보드에 포함된 유저의 수", + example: 100, + }, rank: { type: "number", description: "유저의 리더보드 순위. 1부터 시작합니다.", @@ -68,6 +125,11 @@ publicNoticeDocs[`${apiPrefix}/leaderboard`] = { description: "1등 당첨 확률", example: 0.00003, }, + probabilityV2: { + type: "number", + description: "근사적인 상품 당첨 확률", + example: 0.00045, + }, }, }, }, diff --git a/src/lottery/routes/docs/quests.js b/src/lottery/routes/docs/quests.js new file mode 100644 index 00000000..7ba5a42c --- /dev/null +++ b/src/lottery/routes/docs/quests.js @@ -0,0 +1,43 @@ +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig.mode}/quests`; + +const eventsDocs = {}; +eventsDocs[`${apiPrefix}/complete/:questId`] = { + post: { + tags: [`${apiPrefix}`], + summary: "퀘스트 완료 요청", + description: "퀘스트의 완료를 요청합니다.", + requestBody: { + description: "", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/completeHandler", + }, + }, + }, + }, + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["result"], + properties: { + result: { + type: "boolean", + description: "성공 여부", + example: true, + }, + }, + }, + }, + }, + }, + }, + }, +}; + +module.exports = eventsDocs; diff --git a/src/lottery/routes/docs/questsSchema.js b/src/lottery/routes/docs/questsSchema.js new file mode 100644 index 00000000..3432cc8c --- /dev/null +++ b/src/lottery/routes/docs/questsSchema.js @@ -0,0 +1,18 @@ +const questsSchema = { + completeHandler: { + type: "object", + required: ["questId"], + properties: { + questId: { + type: "string", + enum: [ + "roomSharing", + "eventSharingOnInstagram", + "purchaseSharingOnInstagram", + ], + }, + }, + }, +}; + +module.exports = questsSchema; diff --git a/src/lottery/routes/docs/swaggerDocs.js b/src/lottery/routes/docs/swaggerDocs.js index 96c7fe86..73fa7b66 100644 --- a/src/lottery/routes/docs/swaggerDocs.js +++ b/src/lottery/routes/docs/swaggerDocs.js @@ -1,11 +1,15 @@ -const { eventMode } = require("../../../../loadenv"); const globalStateDocs = require("./globalState"); const itemsDocs = require("./items"); +const publicNoticeDocs = require("./publicNotice"); +const questsDocs = require("./quests"); const transactionsDocs = require("./transactions"); + const itemsSchema = require("./itemsSchema"); -const publicNoticeDocs = require("./publicNotice"); +const globalStateSchema = require("./globalStateSchema"); +const questsSchema = require("./questsSchema"); -const apiPrefix = `/events/${eventMode}`; +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig.mode}`; const eventSwaggerDocs = { tags: [ @@ -18,23 +22,30 @@ const eventSwaggerDocs = { description: "이벤트 - 아이템 관련 API", }, { - name: `${apiPrefix}/transactions`, - description: "이벤트 - 입출금 내역 관련 API", + name: `${apiPrefix}/public-notice`, + description: "이벤트 - 아이템 구매, 뽑기, 획득 공지 관련 API", }, { - name: `${apiPrefix}/public-notice`, - description: "이벤트 - 공지사항 관련 API", + name: `${apiPrefix}/quests`, + description: "이벤트 - 퀘스트 관련 API", + }, + { + name: `${apiPrefix}/transactions`, + description: "이벤트 - 입출금 내역 관련 API", }, ], paths: { ...globalStateDocs, ...itemsDocs, - ...transactionsDocs, ...publicNoticeDocs, + ...questsDocs, + ...transactionsDocs, }, components: { schemas: { + ...globalStateSchema, ...itemsSchema, + ...questsSchema, }, }, }; diff --git a/src/lottery/routes/docs/transactions.js b/src/lottery/routes/docs/transactions.js index 60e7435b..9bb82f41 100644 --- a/src/lottery/routes/docs/transactions.js +++ b/src/lottery/routes/docs/transactions.js @@ -1,5 +1,5 @@ -const { eventMode } = require("../../../../loadenv"); -const apiPrefix = `/events/${eventMode}/transactions`; +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig.mode}/transactions`; const transactionsDocs = {}; transactionsDocs[`${apiPrefix}/`] = { diff --git a/src/lottery/routes/globalState.js b/src/lottery/routes/globalState.js index d0461218..cb69d782 100644 --- a/src/lottery/routes/globalState.js +++ b/src/lottery/routes/globalState.js @@ -2,12 +2,19 @@ const express = require("express"); const router = express.Router(); const globalStateHandlers = require("../services/globalState"); +const { validateBody } = require("../../middlewares/ajv"); +const globalStateSchema = require("./docs/globalStateSchema"); router.get("/", globalStateHandlers.getUserGlobalStateHandler); -// 아래의 Endpoint 접근 시 로그인 필요 +// 아래의 Endpoint 접근 시 로그인 및 시각 체크 필요 router.use(require("../../middlewares/auth")); +router.use(require("../middlewares/timestampValidator")); -router.post("/create", globalStateHandlers.createUserGlobalStateHandler); +router.post( + "/create", + validateBody(globalStateSchema.createUserGlobalStateHandler), + globalStateHandlers.createUserGlobalStateHandler +); module.exports = router; diff --git a/src/lottery/routes/items.js b/src/lottery/routes/items.js index 95136a5f..21dc47ae 100644 --- a/src/lottery/routes/items.js +++ b/src/lottery/routes/items.js @@ -8,8 +8,9 @@ const itemsSchema = require("./docs/itemsSchema"); router.get("/list", itemsHandlers.listHandler); -// 아래의 Endpoint 접근 시 로그인 필요 +// 아래의 Endpoint 접근 시 로그인 및 시각 체크 필요 router.use(require("../../middlewares/auth")); +router.use(require("../middlewares/timestampValidator")); router.post( "/purchase/:itemId", diff --git a/src/lottery/routes/publicNotice.js b/src/lottery/routes/publicNotice.js index 2e09ee65..ac17e481 100644 --- a/src/lottery/routes/publicNotice.js +++ b/src/lottery/routes/publicNotice.js @@ -3,6 +3,11 @@ const express = require("express"); const router = express.Router(); const publicNoticeHandlers = require("../services/publicNotice"); +// 상점 공지는 로그인을 요구하지 않습니다. +router.get( + "/recentTransactions", + publicNoticeHandlers.getRecentPurchaceItemListHandler +); router.get("/leaderboard", publicNoticeHandlers.getTicketLeaderboardHandler); module.exports = router; diff --git a/src/lottery/routes/quests.js b/src/lottery/routes/quests.js new file mode 100644 index 00000000..6ff1246b --- /dev/null +++ b/src/lottery/routes/quests.js @@ -0,0 +1,18 @@ +const express = require("express"); + +const router = express.Router(); +const questsHandlers = require("../services/quests"); + +const { validateParams } = require("../../middlewares/ajv"); +const questsSchema = require("./docs/questsSchema"); + +router.use(require("../../middlewares/auth")); +router.use(require("../middlewares/timestampValidator")); + +router.post( + "/complete/:questId", + validateParams(questsSchema.completeHandler), + questsHandlers.completeHandler +); + +module.exports = router; diff --git a/src/lottery/services/globalState.js b/src/lottery/services/globalState.js index 7c7f7c5f..13ce9bb7 100644 --- a/src/lottery/services/globalState.js +++ b/src/lottery/services/globalState.js @@ -1,12 +1,12 @@ const { eventStatusModel } = require("../modules/stores/mongo"); +const { userModel } = require("../../modules/stores/mongo"); const logger = require("../../modules/logger"); const { isLogin, getLoginInfo } = require("../../modules/auths/login"); -const { eventMode } = require("../../../loadenv"); -const contract = eventMode - ? require(`../modules/contracts/${eventMode}`) - : undefined; -const quests = contract ? Object.values(contract.quests) : undefined; +const { eventConfig } = require("../../../loadenv"); +const contracts = + eventConfig && require(`../modules/contracts/${eventConfig.mode}`); +const quests = contracts ? Object.values(contracts.quests) : undefined; const getUserGlobalStateHandler = async (req, res) => { try { @@ -47,10 +47,16 @@ const createUserGlobalStateHandler = async (req, res) => { eventStatus = new eventStatusModel({ userId: req.userOid, + creditAmount: 100, // 초기 송편 개수는 0개가 아닌 100개입니다. }); await eventStatus.save(); - await contract.completeFirstLoginQuest(req.userOid, req.timestamp); + //logic2. 수집한 유저 전화번호 user Scheme 에 저장 + const user = await userModel.findOne({ _id: req.userOid }); + user.phoneNumber = req.body.phoneNumber; + await user.save(); + + await contracts.completeFirstLoginQuest(req.userOid, req.timestamp); res.json({ result: true }); } catch (err) { diff --git a/src/lottery/services/items.js b/src/lottery/services/items.js index 5e3a5a1c..f122481a 100644 --- a/src/lottery/services/items.js +++ b/src/lottery/services/items.js @@ -20,10 +20,10 @@ const updateEventStatus = async ( } ); -const { eventMode } = require("../../../loadenv"); -const eventPeriod = eventMode - ? require(`../modules/contracts/${eventMode}`).eventPeriod - : undefined; +const hideItemStock = (item) => { + item.stock = item.stock > 0 ? 1 : 0; + return item; +}; const getRandomItem = async (req, depth) => { if (depth >= 10) { @@ -39,9 +39,7 @@ const getRandomItem = async (req, depth) => { }) .lean(); const randomItems = items - .map((item) => { - return Array(item.randomWeight).fill(item); - }) + .map((item) => Array(item.randomWeight).fill(item)) .reduce((a, b) => a.concat(b), []); const dumpRandomItems = randomItems .map((item) => item._id.toString()) @@ -61,14 +59,13 @@ const getRandomItem = async (req, depth) => { // 1단계: 재고를 차감합니다. const newRandomItem = await itemModel .findOneAndUpdate( - { _id: randomItem._id }, + { _id: randomItem._id, stock: { $gt: 0 } }, { $inc: { stock: -1, }, }, { - runValidators: true, new: true, fields: { itemType: 0, @@ -78,6 +75,9 @@ const getRandomItem = async (req, depth) => { } ) .lean(); + if (!newRandomItem) { + throw new Error(`Item ${randomItem._id.toString()} was already sold out`); + } // 2단계: 유저 정보를 업데이트합니다. await updateEventStatus(req.userOid, { @@ -91,7 +91,8 @@ const getRandomItem = async (req, depth) => { amount: 0, userId: req.userOid, item: randomItem._id, - comment: `랜덤 박스에서 "${randomItem.name}" 1개를 획득했습니다.`, + itemType: randomItem.itemType, + comment: `랜덤박스에서 "${randomItem.name}" 1개를 획득했습니다.`, }); await transaction.save(); @@ -109,9 +110,12 @@ const getRandomItem = async (req, depth) => { const listHandler = async (_, res) => { try { const items = await itemModel - .find({}, "name imageUrl price description isDisabled stock itemType") + .find( + {}, + "name imageUrl instagramStoryStickerImageUrl price description isDisabled stock itemType" + ) .lean(); - res.json({ items }); + res.json({ items: items.map(hideItemStock) }); } catch (err) { logger.error(err); res.status(500).json({ error: "Items/List : internal server error" }); @@ -126,9 +130,6 @@ const purchaseHandler = async (req, res) => { .status(400) .json({ error: "Items/Purchase : nonexistent eventStatus" }); - if (req.timestamp >= eventPeriod.end || req.timestamp < eventPeriod.start) - return res.status(400).json({ error: "Items/Purchase : out of date" }); - const { itemId } = req.params; const item = await itemModel.findOne({ _id: itemId }).lean(); if (!item) @@ -147,17 +148,18 @@ const purchaseHandler = async (req, res) => { .json({ error: "Items/Purchase : item out of stock" }); // 1단계: 재고를 차감합니다. - await itemModel.updateOne( - { _id: item._id }, + const { modifiedCount } = await itemModel.updateOne( + { _id: item._id, stock: { $gt: 0 } }, { $inc: { stock: -1, }, - }, - { - runValidators: true, } ); + if (modifiedCount === 0) + return res + .status(400) + .json({ error: "Items/Purchase : item out of stock" }); // 2단계: 유저 정보를 업데이트합니다. await updateEventStatus(req.userOid, { @@ -172,6 +174,7 @@ const purchaseHandler = async (req, res) => { amount: item.price, userId: req.userOid, item: item._id, + itemType: item.itemType, comment: `송편 ${item.price}개를 사용해 "${item.name}" 1개를 획득했습니다.`, }); await transaction.save(); @@ -180,14 +183,34 @@ const purchaseHandler = async (req, res) => { if (item.itemType !== 3) return res.json({ result: true }); const randomItem = await getRandomItem(req, 0); - if (!randomItem) + if (!randomItem) { + // 랜덤박스가 실패한 경우, 상태를 구매 이전으로 되돌립니다. + // TODO: Transactions 도입 후 이 코드는 삭제합니다. + logger.info(`User ${req.userOid}'s status will be restored`); + + await transactionModel.deleteOne({ _id: transaction._id }); + await updateEventStatus(req.userOid, { + creditDelta: item.price, + }); + await itemModel.updateOne( + { _id: item._id }, + { + $inc: { + stock: 1, + }, + } + ); + + logger.info(`User ${req.userOid}'s status was successfully restored`); + return res .status(500) .json({ error: "Items/Purchase : random box error" }); + } res.json({ result: true, - reward: randomItem, + reward: hideItemStock(randomItem), }); } catch (err) { logger.error(err); diff --git a/src/lottery/services/publicNotice.js b/src/lottery/services/publicNotice.js index ac4b2b9d..379e08da 100644 --- a/src/lottery/services/publicNotice.js +++ b/src/lottery/services/publicNotice.js @@ -1,7 +1,95 @@ +const { transactionModel } = require("../modules/stores/mongo"); const { eventStatusModel } = require("../modules/stores/mongo"); const { userModel } = require("../../modules/stores/mongo"); -const logger = require("../../modules/logger"); const { isLogin, getLoginInfo } = require("../../modules/auths/login"); +const logger = require("../../modules/logger"); +const { + publicNoticePopulateOption, +} = require("../modules/populates/transactions"); + +/** + * getValueRank 사용자의 상품 구매 내역 또는 경품 추첨 내역의 순위 결정을 위한 가치를 평가하는 함수 + * 상품 가격이 높을수록, 상품 구매 일시가 최근일 수록 가치가 높습니다. + * 요청이 들어온 시간과 트랜젝션이 있었던 시간의 차를 로그스케일로 변환후 이를 가격에 곱하여 가치를 구합니다. + * 시간의 단위는 millisecond입니다. + * t_1/2(반감기, half-life)는 4일입니다 . + * (2일 = 2 * 24 * 60 * 60 * 1000 = 172800000ms) + * Tau는 반감기를 결정하는 상수입니다. + * Tau = t_1/2 / ln(2) 로 구할 수 있습니다. + * Tau = 249297703 + * N_0(초기값)는 item.price를 사용합니다. + * @param {Object} item + * @param {number|Date} createAt + * @param {number|Date} timestamp + * @returns {Promise} + * @description 가치를 기준으로 정렬하기 위해 사용됨 + */ +const getValueRank = (item, createAt, timestamp) => { + const t = timestamp - new Date(createAt).getTime(); // millisecond + const Tau = 249297703; + return item.price * Math.exp(-t / Tau); +}; + +const getRecentPurchaceItemListHandler = async (req, res) => { + try { + const transactions = ( + await transactionModel + .find({ type: "use", itemType: 0 }) + .sort({ createAt: -1 }) + .limit(1000) + .populate(publicNoticePopulateOption) + .lean() + ) + .sort( + (x, y) => + getValueRank(y.item, y.createAt, req.timestamp) - + getValueRank(x.item, x.createAt, req.timestamp) + ) + .slice(0, 5) + .map(({ userId, item, comment, createAt }) => ({ + text: `${userId.nickname}님께서 ${item.name}${ + comment.startsWith("송편") + ? "을(를) 구입하셨습니다." + : comment.startsWith("랜덤박스") + ? "을(를) 뽑았습니다." + : "을(를) 획득하셨습니다." + }`, + createAt, + })); + res.json({ transactions }); + } catch (err) { + logger.error(err); + res.status(500).json({ + error: "PublicNotice/RecentTransactions : internal server error", + }); + } +}; + +const calculateProbabilityV2 = (users, weightSum, base, weight) => { + // 유저 수가 상품 수보다 적거나 같으면 무조건 상품을 받게된다. + if (users.length <= 15) return 1; + + /** + * 경험적으로 발견한 사실 + * + * p를 에어팟 당첨 확률, M을 전체 티켓 수라고 하자. + * 모든 유저의 p값이 1/15 미만일 경우, 실제 당첨 확률은 15p이다. + * 그렇지 않은 경우, 실제 당첨 확률은 1-a^Mp꼴의 지수함수를 따른다. (Note: Mp는 티켓 수이다.) + * + * 계산 과정 + * + * a는 유저 수, 전체 티켓 수, 티켓 분포에 의해 결정되는 값으로, 현실적으로 계산하기 어렵다. + * 따라서, 모든 유저가 같은 수의 티켓을 가지고 있다고 가정하고 a를 계산한 뒤, 이를 확률 계산에 사용한다. + * + * a값의 계산 과정 + * + * N을 유저 수라고 하자. 모든 유저가 같은 수의 티켓 M/N개를 가지고 있다고 하자. + * 이때 기대되는 당첨 확률은 직관적으로 15/N임을 알 수 있다. 즉, 1-a^(M/N) = 15/N이다. + * a에 대해 정리하면, a = (1-15/N)^(N/M)임을 알 수 있다. + */ + if (base !== null) return 1 - Math.pow(base, weight); + else return (weight / weightSum) * 15; +}; const getTicketLeaderboardHandler = async (req, res) => { try { @@ -22,12 +110,30 @@ const getTicketLeaderboardHandler = async (req, res) => { const userId = isLogin(req) ? getLoginInfo(req).oid : null; let rank = -1; - const weightSum = sortedUsers.reduce((before, user, index) => { - if (rank < 0 && user.userId === userId) { - rank = index; - } - return before + user.weight; - }, 0); + const [weightSum, totalTicket1Amount, totalTicket2Amount] = + sortedUsers.reduce( + ( + [_weightSum, _totalTicket1Amount, _totalTicket2Amount], + user, + index + ) => { + if (rank < 0 && user.userId === userId) { + rank = index; + } + return [ + _weightSum + user.weight, + _totalTicket1Amount + user.ticket1Amount, + _totalTicket2Amount + user.ticket2Amount, + ]; + }, + [0, 0, 0] + ); + + const isExponential = + sortedUsers.find((user) => user.weight >= weightSum / 15) !== undefined; + const base = isExponential + ? Math.pow(1 - 15 / users.length, users.length / weightSum) + : null; const leaderboard = await Promise.all( sortedUsers.slice(0, 20).map(async (user) => { @@ -36,13 +142,18 @@ const getTicketLeaderboardHandler = async (req, res) => { logger.error(`Fail to find user ${user.userId}`); return null; } - return { nickname: userInfo.nickname, profileImageUrl: userInfo.profileImageUrl, ticket1Amount: user.ticket1Amount, ticket2Amount: user.ticket2Amount, probability: user.weight / weightSum, + probabilityV2: calculateProbabilityV2( + users, + weightSum, + base, + user.weight + ), }; }) ); @@ -51,13 +162,24 @@ const getTicketLeaderboardHandler = async (req, res) => { .status(500) .json({ error: "PublicNotice/Leaderboard : internal server error" }); - if (rank >= 0) - res.json({ - leaderboard, - rank: rank + 1, - probability: sortedUsers[rank].weight / weightSum, - }); - else res.json({ leaderboard }); + res.json({ + leaderboard, + totalTicket1Amount, + totalTicket2Amount, + totalUserAmount: users.length, + ...(rank >= 0 + ? { + rank: rank + 1, + probability: sortedUsers[rank].weight / weightSum, + probabilityV2: calculateProbabilityV2( + users, + weightSum, + base, + sortedUsers[rank].weight + ), + } + : {}), + }); } catch (err) { logger.error(err); res @@ -67,5 +189,6 @@ const getTicketLeaderboardHandler = async (req, res) => { }; module.exports = { + getRecentPurchaceItemListHandler, getTicketLeaderboardHandler, }; diff --git a/src/lottery/services/quests.js b/src/lottery/services/quests.js new file mode 100644 index 00000000..5dd4f0e5 --- /dev/null +++ b/src/lottery/services/quests.js @@ -0,0 +1,25 @@ +const { completeQuest } = require("../modules/quests"); +const logger = require("../../modules/logger"); + +const { eventConfig } = require("../../../loadenv"); +const quests = eventConfig + ? require(`../modules/contracts/${eventConfig.mode}`).quests + : undefined; + +const completeHandler = async (req, res) => { + try { + const quest = quests[req.params.questId]; + if (!quest || !quest.isApiRequired) + return res.status(400).json({ error: "Quests/Complete: invalid Quest" }); + + const result = await completeQuest(req.userOid, req.timestamp, quest); + res.json({ result: !!result }); // boolean으로 변환하기 위해 !!를 사용합니다. + } catch (err) { + logger.error(err); + res.status(500).json({ error: "Quests/Complete: internal server error" }); + } +}; + +module.exports = { + completeHandler, +}; diff --git a/src/lottery/services/transactions.js b/src/lottery/services/transactions.js index c151d81f..695cf3bf 100644 --- a/src/lottery/services/transactions.js +++ b/src/lottery/services/transactions.js @@ -4,6 +4,13 @@ const { transactionPopulateOption, } = require("../modules/populates/transactions"); +const hideItemStock = (transaction) => { + if (transaction.item) { + transaction.item.stock = transaction.item.stock > 0 ? 1 : 0; + } + return transaction; +}; + const getUserTransactionsHandler = async (req, res) => { try { // userId는 이미 Frontend에서 알고 있고, 중복되는 값이므로 제외합니다. @@ -13,7 +20,7 @@ const getUserTransactionsHandler = async (req, res) => { .lean(); if (transactions) res.json({ - transactions, + transactions: transactions.map(hideItemStock), }); else res.status(500).json({ error: "Transactions/ : internal server error" }); diff --git a/src/middlewares/limitRate.js b/src/middlewares/limitRate.js index b218b0ba..4cba6af3 100644 --- a/src/middlewares/limitRate.js +++ b/src/middlewares/limitRate.js @@ -2,7 +2,7 @@ const rateLimit = require("express-rate-limit"); const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes - max: 1500, // Limit each IP to 100 requests per `window` (here, per 15 minutes) + max: 1500, // Limit each IP to 1500 requests per `window` (here, per 15 minutes) standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers }); diff --git a/src/modules/adminResource.js b/src/modules/adminResource.js index b75013b3..f5ba3eb8 100644 --- a/src/modules/adminResource.js +++ b/src/modules/adminResource.js @@ -72,7 +72,7 @@ const recordActionAfterHandler = (actions) => async (res, req, context) => { return res; }; -const recordAction = (actionName, handler, logActions) => ({ +const buildRecordAction = (actionName, handler, logActions) => ({ actionName, actionType: "record", component: false, @@ -100,6 +100,6 @@ const buildResource = module.exports = { generateTarget, - recordAction, + buildRecordAction, buildResource, }; diff --git a/src/modules/stores/mongo.js b/src/modules/stores/mongo.js index bd73367f..119e0c0d 100755 --- a/src/modules/stores/mongo.js +++ b/src/modules/stores/mongo.js @@ -12,6 +12,7 @@ const userSchema = Schema({ ongoingRoom: [{ type: Schema.Types.ObjectId, ref: "Room" }], // 참여중인 진행중인 방 배열 doneRoom: [{ type: Schema.Types.ObjectId, ref: "Room" }], // 참여중인 완료된 방 배열 withdraw: { type: Boolean, default: false }, + phoneNumber: { type: String }, // 전화번호 (2023FALL 이벤트부터 추가) ban: { type: Boolean, default: false }, //계정 정지 여부 joinat: { type: Date, required: true }, //가입 시각 agreeOnTermsOfService: { type: Boolean, default: false }, //이용약관 동의 여부 diff --git a/src/routes/admin.js b/src/routes/admin.js index d476142e..e2ddaae4 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -13,7 +13,7 @@ const { deviceTokenModel, notificationOptionModel, } = require("../modules/stores/mongo"); -const { eventMode } = require("../../loadenv"); +const { eventConfig } = require("../../loadenv"); const { buildResource } = require("../modules/adminResource"); const router = express.Router(); @@ -37,7 +37,7 @@ const baseResources = [ notificationOptionModel, ].map(buildResource()); const resources = baseResources.concat( - eventMode === "2023fall" ? require("../lottery").resources : [] + eventConfig?.mode === "2023fall" ? require("../lottery").resources : [] ); // Create router for admin page diff --git a/src/services/rooms.js b/src/services/rooms.js index 4ef35731..d4b7557e 100644 --- a/src/services/rooms.js +++ b/src/services/rooms.js @@ -497,7 +497,11 @@ const commitPaymentHandler = async (req, res) => { req.timestamp, roomObject ); - await contracts?.completePayingAndSendingQuest(req.timestamp, roomObject); + await contracts?.completePayingAndSendingQuest( + req.userOid, + req.timestamp, + roomObject + ); // 수정한 방 정보를 반환합니다. res.send(formatSettlement(roomObject, { isOver: true })); @@ -571,7 +575,11 @@ const settlementHandler = async (req, res) => { req.timestamp, roomObject ); - await contracts?.completePayingAndSendingQuest(req.timestamp, roomObject); + await contracts?.completePayingAndSendingQuest( + req.userOid, + req.timestamp, + roomObject + ); // 수정한 방 정보를 반환합니다. res.send(formatSettlement(roomObject, { isOver: true }));