diff --git a/app.js b/app.js index d8a1f0f3..689de7a9 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"); @@ -43,8 +43,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 d06be299..a24af419 100644 --- a/src/lottery/index.js +++ b/src/lottery/index.js @@ -1,21 +1,16 @@ const express = require("express"); const { eventStatusModel, - eventModel, + questModel, itemModel, transactionModel, } = require("./modules/stores/mongo"); +const { eventConfig } = require("../../loadenv"); const { buildResource } = require("../modules/adminResource"); -const { instagramRewardAction } = require("./modules/admin"); // [Routes] 기존 docs 라우터의 docs extend -require("./routes/docs")(); - -// [Middleware] 목표 달성 여부 검증 -const checkReward = (req, res, next) => { - next(); -}; +eventConfig && require("./routes/docs")(); const lotteryRouter = express.Router(); @@ -26,16 +21,21 @@ lotteryRouter.use(require("../middlewares/originValidator")); 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 eventStatusResource = buildResource([instagramRewardAction])( - eventStatusModel -); -const otherResources = [eventModel, itemModel, transactionModel].map( - buildResource() -); +const contracts = + eventConfig && require(`./modules/contracts/${eventConfig.mode}`); module.exports = { - checkReward, lotteryRouter, - resources: [eventStatusResource, ...otherResources], + resources, + 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/admin.js b/src/lottery/modules/admin.js deleted file mode 100644 index 337d7d96..00000000 --- a/src/lottery/modules/admin.js +++ /dev/null @@ -1,74 +0,0 @@ -const { useUserCreditAmount } = require("./credit"); -const { transactionModel } = require("./stores/mongo"); -const { recordAction } = require("../../modules/adminResource"); -const { eventEnv } = require("../../../loadenv"); - -/** eventId가 없는 경우 null이 아닌 undefined를 넣어야 합니다. */ -const creditTransfer = async (userId, amount, eventId, comment) => { - const user = await useUserCreditAmount(userId); - await user.update(amount); - - const transaction = new transactionModel({ - type: "get", - amount, - userId, - event: eventId, - comment, - }); - await transaction.save(); - - return transaction._id; -}; - -/** itemId가 없는 경우 null이 아닌 undefined를 넣어야 합니다. */ -/** itemType이 없는 경우 null이 아닌 undefined를 넣어야 합니다. */ -const creditWithdraw = async (userId, amount, itemId, itemType, comment) => { - const user = await useUserCreditAmount(userId); - await user.update(-amount); - - const transaction = new transactionModel({ - type: "use", - amount, - userId, - item: itemId, - itemType, - comment, - }); - await transaction.save(); - - return transaction._id; -}; - -const instagramRewardActionHandler = async (req, res, context) => { - const transactionId = await creditTransfer( - context?.record?.params?.userId, - eventEnv.instagramReward, - eventEnv.instagramEventId, - eventEnv.instagramComment - ); - - let record = context.record.toJSON(context.currentAdmin); - record.params.creditAmount += eventEnv.instagramReward; - - return { - record, - transactionId, - }; -}; -const instagramRewardActionLogs = [ - "update", - { - action: "create", - target: (res, req, context) => `Transaction(_id = ${res.transactionId})`, - }, -]; - -const instagramRewardAction = recordAction( - "instagramReward", - instagramRewardActionHandler, - instagramRewardActionLogs -); - -module.exports = { - instagramRewardAction, -}; diff --git a/src/lottery/modules/contracts/2023fall.js b/src/lottery/modules/contracts/2023fall.js new file mode 100644 index 00000000..1677c26e --- /dev/null +++ b/src/lottery/modules/contracts/2023fall.js @@ -0,0 +1,263 @@ +const { buildQuests, completeQuest } = require("../quests"); +const mongoose = require("mongoose"); + +/** 전체 퀘스트 목록입니다. */ +const quests = buildQuests({ + firstLogin: { + 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: "함께하는 택시의 여정", + 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: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_firstRoomCreation.png", + reward: 50, + }, + roomSharing: { + name: "Taxi로 모여라", + description: + "방을 공유해 친구들을 택시에 초대해보세요. 채팅창 상단의 햄버거(☰) 버튼을 누르면 공유하기 버튼을 찾을 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_roomSharing.png", + reward: 50, + }, + paying: { + 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: "송금 완료! 친구야 고마워", + 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: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_nicknameChanging.png", + reward: 50, + }, + accountChanging: { + name: "계좌 등록은 정산의 시작", + description: + "정산하기 기능을 더욱 빠르고 이용할 수 있다고? 계좌번호를 등록하면 정산하기를 할 때 계좌가 자동으로 입력돼요. 마이페이지수정하기 버튼을 눌러 계좌번호를 등록 또는 수정할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_accountChanging.png", + reward: 50, + }, + adPushAgreement: { + 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: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_eventSharingOnInstagram.png", + reward: 100, + }, + purchaseSharingOnInstagram: { + name: "상품 획득을 축하합니다", + description: + "이벤트를 열심히 즐긴 당신. 그 상품 획득을 축하 받을 자격이 충분합니다. 달토끼 상점에서 상품 구매 후 뜨는 인스타그램 스토리에 공유하기 버튼을 눌러 상품 획득을 공유하세요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_purchaseSharingOnInstagram.png", + reward: 100, + }, +}); + +/** + * firstLogin 퀘스트의 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @returns {Promise} + * @usage lottery/globalState/createUserGlobalStateHandler + */ +const completeFirstLoginQuest = async (userId, timestamp) => { + return await completeQuest(userId, timestamp, quests.firstLogin); +}; + +/** + * payingAndSending 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이면 요청하지 않습니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @param {Object} roomObject - 방의 정보입니다. + * @param {Array<{ user: mongoose.Types.ObjectId }>} roomObject.part - 참여자 목록입니다. + * @returns {Promise} + * @description 정산 요청 또는 송금이 이루어질 때마다 호출해 주세요. + * @usage rooms - commitPaymentHandler, rooms - settlementHandler + */ +const completePayingAndSendingQuest = async (userId, timestamp, roomObject) => { + if (roomObject.part.length < 2) return null; + + return await completeQuest(userId, timestamp, quests.payingAndSending); +}; + +/** + * firstRoomCreation 퀘스트의 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @returns {Promise} + * @description 방을 만들 때마다 호출해 주세요. + * @usage rooms - createHandler + */ +const completeFirstRoomCreationQuest = async (userId, timestamp) => { + return await completeQuest(userId, timestamp, quests.firstRoomCreation); +}; + +const completeRoomSharingQuest = async () => { + // TODO +}; + +/** + * paying 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이면 요청하지 않습니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @param {Object} roomObject - 방의 정보입니다. + * @param {Array<{ user: mongoose.Types.ObjectId }>} roomObject.part - 참여자 목록입니다. + * @returns {Promise} + * @description 정산 요청이 이루어질 때마다 호출해 주세요. + * @usage rooms - commitPaymentHandler + */ +const completePayingQuest = async (userId, timestamp, roomObject) => { + if (roomObject.part.length < 2) return null; + + return await completeQuest(userId, timestamp, quests.paying); +}; + +/** + * sending 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이면 요청하지 않습니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @param {Object} roomObject - 방의 정보입니다. + * @param {Array<{ user: mongoose.Types.ObjectId }>} roomObject.part - 참여자 목록입니다. + * @returns {Promise} + * @description 송금이 이루어질 때마다 호출해 주세요. + * @usage rooms - settlementHandler + */ +const completeSendingQuest = async (userId, timestamp, roomObject) => { + if (roomObject.part.length < 2) return null; + + return await completeQuest(userId, timestamp, quests.sending); +}; + +/** + * nicknameChanging 퀘스트의 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @returns {Promise} + * @description 닉네임을 변경할 때마다 호출해 주세요. + * @usage users - editNicknameHandler + */ +const completeNicknameChangingQuest = async (userId, timestamp) => { + return await completeQuest(userId, timestamp, quests.nicknameChanging); +}; + +/** + * accountChanging 퀘스트의 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @param {string} newAccount - 변경된 계좌입니다. + * @returns {Promise} + * @description 계좌를 변경할 때마다 호출해 주세요. + * @usage users - editAccountHandler + */ +const completeAccountChangingQuest = async (userId, timestamp, newAccount) => { + if (newAccount === "") return null; + + return await completeQuest(userId, timestamp, quests.accountChanging); +}; + +/** + * adPushAgreementQuest 퀘스트의 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @param {boolean} advertisement - 변경된 광고성 알림 수신 동의 여부입니다. + * @returns {Promise} + * @description 알림 옵션을 변경할 때마다 호출해 주세요. + * @usage notifications/editOptionsHandler + */ +const completeAdPushAgreementQuest = async ( + userId, + timestamp, + advertisement +) => { + if (!advertisement) return null; + + return await completeQuest(userId, timestamp, quests.adPushAgreement); +}; + +/** + * eventSharingOnInstagram 퀘스트의 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @returns {Promise} + * @description 인스타그램 스토리에 추석 이벤트를 공유할 때마다 호출해 주세요. + * @usage quests - instagramEventShareHandler + */ +const completeEventSharingOnInstagramQuest = async (userId, timestamp) => { + return await completeQuest(userId, timestamp, quests.eventSharingOnInstagram); +}; + +/** + * purchaseSharingOnInstagram 퀘스트의 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @returns {Promise} + * @description 인스타그램 스토리에 구매한 아이템을 공유할 때마다 호출해 주세요. + * @usage quests - instagramPurchaseShareHandler + */ +const completePurchaseSharingOnInstagramQuest = async (userId, timestamp) => { + return await completeQuest( + userId, + timestamp, + quests.purchaseSharingOnInstagram + ); +}; + +module.exports = { + quests, + completeFirstLoginQuest, + completePayingAndSendingQuest, + completeFirstRoomCreationQuest, + completeRoomSharingQuest, + completePayingQuest, + completeSendingQuest, + completeNicknameChangingQuest, + completeAccountChangingQuest, + completeAdPushAgreementQuest, + completeEventSharingOnInstagramQuest, + completePurchaseSharingOnInstagramQuest, +}; diff --git a/src/lottery/modules/credit.js b/src/lottery/modules/credit.js deleted file mode 100644 index 2349d0c4..00000000 --- a/src/lottery/modules/credit.js +++ /dev/null @@ -1,24 +0,0 @@ -const { eventStatusModel } = require("../modules/stores/mongo"); - -const useUserCreditAmount = async (userId) => { - const eventStatus = await eventStatusModel.findOne({ userId }).lean(); - if (!eventStatus) return null; - - return { - amount: eventStatus.creditAmount, - update: async (delta) => { - await eventStatusModel.updateOne( - { _id: eventStatus._id }, - { - $inc: { - creditAmount: delta, - }, - } - ); - }, - }; -}; - -module.exports = { - useUserCreditAmount, -}; diff --git a/src/lottery/modules/populates/transactions.js b/src/lottery/modules/populates/transactions.js index f4206d87..bcf84863 100644 --- a/src/lottery/modules/populates/transactions.js +++ b/src/lottery/modules/populates/transactions.js @@ -1,11 +1,22 @@ const transactionPopulateOption = [ - { path: "event" }, { path: "item", select: "name imageUrl 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 new file mode 100644 index 00000000..26bf2e24 --- /dev/null +++ b/src/lottery/modules/quests.js @@ -0,0 +1,162 @@ +const { + eventStatusModel, + questModel, + itemModel, + transactionModel, +} = require("./stores/mongo"); +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)) { + // quest에 필수 필드가 모두 포함되어 있는지 확인합니다. + const hasError = requiredQuestFields.reduce((before, field) => { + if (quest[field] !== undefined) return before; + + logger.error(`There is no ${field} field in ${id}Quest`); + return true; + }, false); + if (hasError) return null; + + // quest.id 필드를 설정합니다. + quest.id = id; + + // quest.reward가 number인 경우, object로 변환합니다. + if (typeof quest.reward === "number") { + const credit = quest.reward; + quest.reward = { + credit, + }; + } + + // quest.reward에 누락된 필드가 있는 경우, 기본값(0)으로 설정합니다. + quest.reward.credit = quest.reward.credit || 0; + quest.reward.ticket1 = quest.reward.ticket1 || 0; + + // quest.maxCount가 없는 경우, 기본값(1)으로 설정합니다. + quest.maxCount = quest.maxCount || 1; + } + + return quests; +}; + +/** + * 퀘스트 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @param {Object} quest - 퀘스트의 정보입니다. + * @param {string} quest.id - 퀘스트의 Id입니다. + * @param {string} quest.name - 퀘스트의 이름입니다. + * @param {Object} quest.reward - 퀘스트의 완료 보상입니다. + * @param {number} quest.reward.credit - 퀘스트의 완료 보상 중 재화의 양입니다. + * @param {number} quest.reward.ticket1 - 퀘스트의 완료 보상 중 일반 티켓의 개수입니다. + * @param {number} quest.maxCount - 퀘스트의 최대 완료 가능 횟수입니다. + * @returns {Object|null} 성공한 경우 Object를, 실패한 경우 null을 반환합니다. 이미 최대 완료 횟수에 도달했거나, 퀘스트가 원격으로 비활성화 된 경우에도 실패로 처리됩니다. + */ +const completeQuest = async (userId, timestamp, quest) => { + try { + // 1단계: 유저의 EventStatus를 가져옵니다. + const eventStatus = await eventStatusModel.findOne({ userId }).lean(); + if (!eventStatus) return null; + + // 2단계: 이벤트 기간인지 확인합니다. + if (timestamp >= eventPeriod.endAt || timestamp < eventPeriod.startAt) { + logger.info( + `User ${userId} failed to complete auto-disabled ${quest.id}Quest` + ); + return null; + } + + // 3단계: 유저의 퀘스트 완료 횟수를 확인합니다. + const questCount = eventStatus.completedQuests.filter( + (completedQuestId) => completedQuestId === quest.id + ).length; + if (questCount >= quest.maxCount) { + logger.info( + `User ${userId} already completed ${quest.id}Quest ${questCount} times` + ); + return null; + } + + // 4단계: 원격으로 비활성화된 퀘스트인지 확인합니다. + // 비활성화된 퀘스트만 DB에 저장할 것이기 때문에, questDoc이 null이어도 오류를 발생시키면 안됩니다. + const questDoc = await questModel.findOne({ id: quest.id }).lean(); + if (questDoc?.isDisabled) { + logger.info( + `User ${userId} failed to complete disabled ${quest.id}Quest` + ); + return null; + } + + // 5단계: 완료 보상 중 티켓이 있는 경우, 티켓 정보를 가져옵니다. + const ticket1 = + quest.reward.ticket1 && (await itemModel.findOne({ itemType: 1 }).lean()); + if (quest.reward.ticket1 && !ticket1) throw "Fail to find ticket1"; + + // 6단계: 유저의 EventStatus를 업데이트합니다. + await eventStatusModel.updateOne( + { userId }, + { + $inc: { + creditAmount: quest.reward.credit, + ticket1Amount: quest.reward.ticket1, + }, + $push: { + completedQuests: quest.id, + }, + } + ); + + // 7단계: Transaction을 생성합니다. + const transactionsId = []; + if (quest.reward.credit) { + const transaction = new transactionModel({ + type: "get", + amount: quest.reward.credit, + userId, + questId: quest.id, + comment: `"${quest.name}" 퀘스트를 완료해 송편 ${quest.reward.credit}개를 획득했습니다.`, + }); + await transaction.save(); + + transactionsId.push(transaction._id); + } + if (quest.reward.ticket1) { + const transaction = new transactionModel({ + type: "use", + amount: 0, + userId, + questId: quest.id, + item: ticket1._id, + comment: `"${quest.name}" 퀘스트를 완료해 "${ticket1.name}" ${quest.reward.ticket1}개를 획득했습니다.`, + }); + await transaction.save(); + + transactionsId.push(transaction._id); + } + + logger.info(`User ${userId} successfully completed ${quest.id}Quest`); + return { + quest, + transactionsId, + }; + } catch (err) { + logger.error(err); + logger.error( + `User ${userId} failed to complete ${quest.id}Quest due to exception` + ); + return null; + } +}; + +module.exports = { + buildQuests, + completeQuest, +}; diff --git a/src/lottery/modules/stores/mongo.js b/src/lottery/modules/stores/mongo.js index 8675851a..2e32978d 100644 --- a/src/lottery/modules/stores/mongo.js +++ b/src/lottery/modules/stores/mongo.js @@ -12,10 +12,9 @@ const eventStatusSchema = Schema({ ref: "User", required: true, }, - eventList: { - type: [Schema.Types.ObjectId], + completedQuests: { + type: [String], default: [], - ref: "Event", }, creditAmount: { type: Number, @@ -23,39 +22,28 @@ const eventStatusSchema = Schema({ min: 0, validate: integerValidator, }, -}); - -const eventSchema = Schema({ - name: { - type: String, - required: true, - }, - rewardAmount: { + ticket1Amount: { type: Number, - required: true, + default: 0, min: 0, validate: integerValidator, }, - maxCount: { + ticket2Amount: { type: Number, - default: 1, + default: 0, min: 0, validate: integerValidator, }, - expireat: { - type: Date, +}); + +const questSchema = Schema({ + id: { + type: String, required: true, + unique: true, }, isDisabled: { type: Boolean, - default: false, - }, - imageUrl: { - type: String, - required: true, - }, - description: { - type: String, required: true, }, }); @@ -123,9 +111,8 @@ const transactionSchema = Schema({ ref: "User", required: true, }, - event: { - type: Schema.Types.ObjectId, - ref: "Event", + questId: { + type: String, }, item: { type: Schema.Types.ObjectId, @@ -141,13 +128,13 @@ const transactionSchema = Schema({ }, }); transactionSchema.set("timestamps", { - createdAt: "doneat", + createdAt: "createAt", updatedAt: false, }); module.exports = { eventStatusModel: mongoose.model("EventStatus", eventStatusSchema), - eventModel: mongoose.model("Event", eventSchema), + questModel: mongoose.model("Quest", questSchema), itemModel: mongoose.model("Item", itemSchema), transactionModel: mongoose.model("Transaction", transactionSchema), }; diff --git a/src/lottery/routes/docs/eventsSchema.js b/src/lottery/routes/docs/eventsSchema.js deleted file mode 100644 index 04fcb616..00000000 --- a/src/lottery/routes/docs/eventsSchema.js +++ /dev/null @@ -1,67 +0,0 @@ -/** Event에 대한 기본적인 프로퍼티를 갖고 있는 스키마입니다. */ -const eventBase = { - type: "object", - required: [ - "_id", - "name", - "rewardAmount", - "maxCount", - "expireat", - "isDisabled", - "imageUrl", - "description", - ], - properties: { - _id: { - type: "string", - description: "Event의 ObjectId", - example: "OBJECT ID", - }, - name: { - type: "string", - description: "이벤트의 이름", - example: "최초 로그인 이벤트", - }, - rewardAmount: { - type: "number", - description: "달성 보상", - example: 100, - }, - maxCount: { - type: "number", - description: "최대 달성 가능 횟수", - example: 1, - }, - expireat: { - type: "string", - description: "달성할 수 있는 마지막 시각", - example: "2023-01-01 00:00:00", - }, - isDisabled: { - type: "boolean", - description: "달성 불가능 여부", - example: false, - }, - imageUrl: { - type: "string", - description: "이미지 썸네일 URL", - example: "THUMBNAIL URL", - }, - description: { - type: "string", - description: "이벤트의 설명", - example: "처음으로 이벤트 기간 중 Taxi에 로그인하면 송편을 드립니다.", - }, - }, -}; - -const eventsSchema = { - event: eventBase, - relatedEvent: { - ...eventBase, - description: - "Transaction과 관련된 이벤트의 Object. 이벤트와 관련된 Transaction인 경우에만 포함됩니다.", - }, -}; - -module.exports = eventsSchema; diff --git a/src/lottery/routes/docs/globalState.js b/src/lottery/routes/docs/globalState.js index e350e35c..4cf9630b 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}/`] = { @@ -7,7 +7,7 @@ globalStateDocs[`${apiPrefix}/`] = { tags: [`${apiPrefix}`], summary: "Frontend에서 Global state로 관리하는 정보 반환", description: - "유저의 재화 개수, 이벤트 달성 상태, 추첨권 개수 등 Frontend에서 Global state로 관리할 정보를 가져옵니다. 유저에 대한 EventStatus Document가 없을 경우 새롭게 생성하며, 유일한 생성 지점입니다.", + "유저의 재화 개수, 퀘스트 완료 상태 등 Frontend에서 Global state로 관리할 정보를 가져옵니다.", responses: { 200: { description: "", @@ -16,26 +16,32 @@ globalStateDocs[`${apiPrefix}/`] = { schema: { type: "object", required: [ + "isAgreeOnTermsOfEvent", "creditAmount", - "eventStatus", + "completedQuests", "ticket1Amount", "ticket2Amount", - "events", + "quests", ], properties: { + isAgreeOnTermsOfEvent: { + type: "boolean", + description: "유저의 이벤트 참여 동의 여부", + example: true, + }, creditAmount: { type: "number", description: "재화 개수. 0 이상입니다.", example: 10000, }, - eventStatus: { + completedQuests: { type: "array", description: - "유저가 달성한 이벤트의 배열. 여러 번 달성할 수 있는 이벤트의 경우 배열 내에 같은 이벤트가 여러 번 포함될 수 있습니다.", + "유저가 완료한 퀘스트의 배열. 여러 번 완료할 수 있는 퀘스트의 경우 배열 내에 같은 퀘스트가 여러 번 포함됩니다.", items: { type: "string", - description: "Event의 ObjectId", - example: "OBJECT ID", + description: "Quest의 Id", + example: "QUEST ID", }, }, ticket1Amount: { @@ -48,11 +54,64 @@ globalStateDocs[`${apiPrefix}/`] = { description: "고급 티켓의 개수. 0 이상입니다.", example: 10, }, - events: { + quests: { type: "array", - description: "Event의 배열", + description: "Quest의 배열", items: { - $ref: "#/components/schemas/event", + type: "object", + required: [ + "id", + "name", + "description", + "imageUrl", + "reward", + "maxCount", + ], + properties: { + id: { + type: "string", + description: "Quest의 Id", + example: "QUEST ID", + }, + name: { + type: "string", + description: "퀘스트의 이름", + example: "최초 로그인 퀘스트", + }, + description: { + type: "string", + description: "퀘스트의 설명", + example: + "처음으로 이벤트 기간 중 Taxi에 로그인하면 송편을 드립니다.", + }, + imageUrl: { + type: "string", + description: "이미지 썸네일 URL", + example: "THUMBNAIL URL", + }, + reward: { + type: "object", + description: "완료 보상", + required: ["credit", "ticket1"], + properties: { + credit: { + type: "number", + description: "완료 보상 중 재화의 개수입니다.", + example: 100, + }, + ticket1: { + type: "number", + description: "완료 보상 중 일반 티켓의 개수입니다.", + example: 1, + }, + }, + }, + maxCount: { + type: "number", + description: "최대 완료 가능 횟수", + example: 1, + }, + }, }, }, }, @@ -63,5 +122,43 @@ globalStateDocs[`${apiPrefix}/`] = { }, }, }; +globalStateDocs[`${apiPrefix}/create`] = { + get: { + tags: [`${apiPrefix}`], + summary: "Frontend에서 Global state로 관리하는 정보 생성", + description: + "유저의 재화 개수, 퀘스트 완료 상태 등 Frontend에서 Global state로 관리할 정보를 생성합니다.", + requestBody: { + description: "Update an existent user in the store", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/createUserGlobalStateHandler", + }, + }, + }, + }, + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["result"], + properties: { + result: { + type: "boolean", + description: "성공 여부. 항상 true입니다.", + example: true, + }, + }, + }, + }, + }, + }, + }, + }, +}; module.exports = globalStateDocs; 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..4079795a 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`] = { diff --git a/src/lottery/routes/docs/itemsSchema.js b/src/lottery/routes/docs/itemsSchema.js index 227f2b08..1601f237 100644 --- a/src/lottery/routes/docs/itemsSchema.js +++ b/src/lottery/routes/docs/itemsSchema.js @@ -75,6 +75,17 @@ const itemsSchema = { ...itemBase, description: "랜덤박스를 구입한 경우에만 포함됩니다.", }, + purchaseHandler: { + type: "object", + required: ["itemId"], + properties: { + itemId: { + type: "string", + pattern: "^[a-fA-F\\d]{24}$", + }, + }, + errorMessage: "validation: bad request", + }, }; module.exports = itemsSchema; diff --git a/src/lottery/routes/docs/publicNotice.js b/src/lottery/routes/docs/publicNotice.js new file mode 100644 index 00000000..ca30cfb9 --- /dev/null +++ b/src/lottery/routes/docs/publicNotice.js @@ -0,0 +1,111 @@ +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}`], + summary: "리더보드 반환", + description: + "티켓 개수(고급 티켓은 일반 티켓 5개와 등가입니다.) 기준의 리더보드와 관련된 정보를 가져옵니다.", + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["leaderboard"], + properties: { + leaderboard: { + type: "array", + description: "상위 20명만 포함된 리더보드", + items: { + type: "object", + required: [ + "nickname", + "profileImageUrl", + "ticket1Amount", + "ticket2Amount", + "probability", + ], + properties: { + nickname: { + type: "string", + description: "유저의 닉네임", + example: "asdf", + }, + profileImageUrl: { + type: "string", + description: "프로필 이미지 URL", + example: "IMAGE URL", + }, + ticket1Amount: { + type: "number", + description: "일반 티켓의 개수. 0 이상입니다.", + example: 10, + }, + ticket2Amount: { + type: "number", + description: "고급 티켓의 개수. 0 이상입니다.", + example: 10, + }, + probability: { + type: "number", + description: "1등 당첨 확률", + example: 0.001, + }, + }, + }, + }, + rank: { + type: "number", + description: "유저의 리더보드 순위. 1부터 시작합니다.", + example: 30, + }, + probability: { + type: "number", + description: "1등 당첨 확률", + example: 0.00003, + }, + }, + }, + }, + }, + }, + }, + }, +}; + +module.exports = publicNoticeDocs; diff --git a/src/lottery/routes/docs/quests.js b/src/lottery/routes/docs/quests.js new file mode 100644 index 00000000..1d6a89c5 --- /dev/null +++ b/src/lottery/routes/docs/quests.js @@ -0,0 +1,61 @@ +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig.mode}/quests`; + +const eventsDocs = {}; +eventsDocs[`${apiPrefix}/instagram/share-event`] = { + post: { + tags: [`${apiPrefix}`], + summary: "eventSharingOnInstagram 퀘스트 완료 요청", + description: "eventSharingOnInstagram 퀘스트의 완료를 요청합니다.", + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["result"], + properties: { + result: { + type: "boolean", + description: "성공 여부", + example: true, + }, + }, + }, + }, + }, + }, + }, + }, +}; + +eventsDocs[`${apiPrefix}/instagram/share-purchase`] = { + post: { + tags: [`${apiPrefix}`], + summary: "purchaseSharingOnInstagram 퀘스트 완료 요청", + description: "purchaseSharingOnInstagram 퀘스트의 완료를 요청합니다.", + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["result"], + properties: { + result: { + description: "성공 여부", + type: "boolean", + example: true, + }, + }, + }, + }, + }, + }, + }, + }, +}; + +module.exports = eventsDocs; diff --git a/src/lottery/routes/docs/swaggerDocs.js b/src/lottery/routes/docs/swaggerDocs.js index efec40c5..77a85f61 100644 --- a/src/lottery/routes/docs/swaggerDocs.js +++ b/src/lottery/routes/docs/swaggerDocs.js @@ -1,11 +1,12 @@ -const { eventMode } = require("../../../../loadenv"); +const { eventConfig } = require("../../../../loadenv"); const globalStateDocs = require("./globalState"); const itemsDocs = require("./items"); const transactionsDocs = require("./transactions"); -const eventsSchema = require("./eventsSchema"); +const questsDocs = require("./quests"); const itemsSchema = require("./itemsSchema"); - -const apiPrefix = `/events/${eventMode}`; +const publicNoticeDocs = require("./publicNotice"); +const globalStateSchema = require("./globalStateSchema"); +const apiPrefix = `/events/${eventConfig.mode}`; const eventSwaggerDocs = { tags: [ @@ -21,16 +22,26 @@ const eventSwaggerDocs = { name: `${apiPrefix}/transactions`, description: "이벤트 - 입출금 내역 관련 API", }, + { + name: `${apiPrefix}/quests`, + description: "이벤트 - 퀘스트 관련 API", + }, + { + name: `${apiPrefix}/public-notice`, + description: "이벤트 - 아이템 구매, 뽑기, 획득 공지 관련 API", + }, ], paths: { ...globalStateDocs, ...itemsDocs, ...transactionsDocs, + ...questsDocs, + ...publicNoticeDocs, }, components: { schemas: { - ...eventsSchema, ...itemsSchema, + ...globalStateSchema, }, }, }; diff --git a/src/lottery/routes/docs/transactions.js b/src/lottery/routes/docs/transactions.js index df1e1cb9..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}/`] = { @@ -21,7 +21,7 @@ transactionsDocs[`${apiPrefix}/`] = { description: "유저의 재화 입출금 기록의 배열", items: { type: "object", - required: ["_id", "type", "amount", "comment", "doneat"], + required: ["_id", "type", "amount", "comment", "createAt"], properties: { _id: { type: "string", @@ -39,8 +39,11 @@ transactionsDocs[`${apiPrefix}/`] = { description: "재화의 변화량의 절댓값", example: 50, }, - event: { - $ref: "#/components/schemas/relatedEvent", + questId: { + type: "string", + description: + "Transaction과 관련된 퀘스트의 Id. 퀘스트와 관련된 Transaction인 경우에만 포함됩니다.", + example: "QUEST ID", }, item: { $ref: "#/components/schemas/relatedItem", @@ -50,7 +53,7 @@ transactionsDocs[`${apiPrefix}/`] = { description: "입출금 내역에 대한 설명", example: "랜덤 상자 구입 - 50개 차감", }, - doneat: { + createAt: { type: "string", description: "입출금이 일어난 시각", example: "2023-01-01 00:00:00", diff --git a/src/lottery/routes/globalState.js b/src/lottery/routes/globalState.js index f0f75406..cb69d782 100644 --- a/src/lottery/routes/globalState.js +++ b/src/lottery/routes/globalState.js @@ -2,10 +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 접근 시 로그인 및 시각 체크 필요 router.use(require("../../middlewares/auth")); +router.use(require("../middlewares/timestampValidator")); -router.get("/", globalStateHandlers.getUserGlobalStateHandler); +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 539288bc..21dc47ae 100644 --- a/src/lottery/routes/items.js +++ b/src/lottery/routes/items.js @@ -2,9 +2,20 @@ const express = require("express"); const router = express.Router(); const itemsHandlers = require("../services/items"); -const auth = require("../../middlewares/auth"); + +const { validateParams } = require("../../middlewares/ajv"); +const itemsSchema = require("./docs/itemsSchema"); router.get("/list", itemsHandlers.listHandler); -router.post("/purchase/:itemId", auth, itemsHandlers.purchaseHandler); + +// 아래의 Endpoint 접근 시 로그인 및 시각 체크 필요 +router.use(require("../../middlewares/auth")); +router.use(require("../middlewares/timestampValidator")); + +router.post( + "/purchase/:itemId", + validateParams(itemsSchema.purchaseHandler), + itemsHandlers.purchaseHandler +); module.exports = router; diff --git a/src/lottery/routes/publicNotice.js b/src/lottery/routes/publicNotice.js new file mode 100644 index 00000000..ac17e481 --- /dev/null +++ b/src/lottery/routes/publicNotice.js @@ -0,0 +1,13 @@ +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..032c96c7 --- /dev/null +++ b/src/lottery/routes/quests.js @@ -0,0 +1,11 @@ +const express = require("express"); +const router = express.Router(); +const quests = require("../services/quests"); + +router.use(require("../../middlewares/auth")); +router.use(require("../middlewares/timestampValidator")); + +router.post("/instagram/share-event", quests.instagramEventShareHandler); +router.post("/instagram/share-purchase", quests.instagramPurchaseShareHandler); + +module.exports = router; diff --git a/src/lottery/services/globalState.js b/src/lottery/services/globalState.js index b771430e..c36ac9f1 100644 --- a/src/lottery/services/globalState.js +++ b/src/lottery/services/globalState.js @@ -1,58 +1,72 @@ -const { - eventStatusModel, - eventModel, - transactionModel, - itemModel, -} = 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 { 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 { + const userId = isLogin(req) ? getLoginInfo(req).oid : null; + const eventStatus = + userId && + (await eventStatusModel.findOne({ userId }, "-_id -userId -__v").lean()); + if (eventStatus) + return res.json({ + isAgreeOnTermsOfEvent: true, + ...eventStatus, + quests, + }); + else + return res.json({ + isAgreeOnTermsOfEvent: false, + completedQuests: [], + creditAmount: 0, + ticket1Amount: 0, + ticket2Amount: 0, + quests, + }); + } catch (err) { + logger.error(err); + res.status(500).json({ error: "GlobalState/ : internal server error" }); + } +}; + +const createUserGlobalStateHandler = async (req, res) => { try { let eventStatus = await eventStatusModel .findOne({ userId: req.userOid }) .lean(); - if (!eventStatus) { - // User마다 EventStatus를 가져야 하고, 현재 Taxi에는 회원 탈퇴 시스템이 없으므로, EventStatus가 없으면 새롭게 생성하도록 구현합니다. - // EventStatus의 생성은 이곳에서만 이루어집니다!! - eventStatus = new eventStatusModel({ - userId: req.userOid, - }); - await eventStatus.save(); - } + if (eventStatus) + return res + .status(400) + .json({ error: "GlobalState/Create : already created" }); - const ticket1Amount = await transactionModel.count({ + eventStatus = new eventStatusModel({ userId: req.userOid, - type: "use", - item: { - $exists: true, - $ne: null, - }, - itemType: 1, - }); - const ticket2Amount = await transactionModel.count({ - userId: req.userOid, - type: "use", - item: { - $exists: true, - $ne: null, - }, - itemType: 2, - }); - const events = await eventModel.find({}, "-__v").lean(); - - res.json({ - creditAmount: eventStatus.creditAmount, - eventStatus: eventStatus.eventList.map((id) => id.toString()), - ticket1Amount, - ticket2Amount, - events, }); + await eventStatus.save(); + + //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) { logger.error(err); - res.status(500).json({ error: "GlobalState/ : internal server error" }); + res + .status(500) + .json({ error: "GlobalState/Create : internal server error" }); } }; module.exports = { getUserGlobalStateHandler, + createUserGlobalStateHandler, }; diff --git a/src/lottery/services/items.js b/src/lottery/services/items.js index d47c2479..ece89fec 100644 --- a/src/lottery/services/items.js +++ b/src/lottery/services/items.js @@ -1,9 +1,30 @@ -const { itemModel, transactionModel } = require("../modules/stores/mongo"); +const { + eventStatusModel, + itemModel, + transactionModel, +} = require("../modules/stores/mongo"); const logger = require("../../modules/logger"); -const { useUserCreditAmount } = require("../modules/credit"); + +const updateEventStatus = async ( + userId, + { creditDelta = 0, ticket1Delta = 0, ticket2Delta = 0 } = {} +) => + await eventStatusModel.updateOne( + { userId }, + { + $inc: { + creditAmount: creditDelta, + ticket1Amount: ticket1Delta, + ticket2Amount: ticket2Delta, + }, + } + ); const getRandomItem = async (req, depth) => { - if (depth >= 10) return null; + if (depth >= 10) { + logger.error(`User ${req.userOid} failed to open random box`); + return null; + } const items = await itemModel .find({ @@ -22,10 +43,9 @@ const getRandomItem = async (req, depth) => { .join(","); logger.info( - `[RandomBox] getRandomItem(depth=${depth}) is called by the user(id=${req.userOid}).` - ); - logger.info( - `[RandomBox] randomItems of the user(id=${req.userOid}) is [${dumpRandomItems}].` + `User ${req.userOid}'s ${ + depth + 1 + }th random box probability is: [${dumpRandomItems}]` ); if (randomItems.length === 0) return null; @@ -33,16 +53,16 @@ const getRandomItem = async (req, depth) => { const randomItem = randomItems[Math.floor(Math.random() * randomItems.length)]; try { + // 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, @@ -52,21 +72,32 @@ const getRandomItem = async (req, depth) => { } ) .lean(); + if (!newRandomItem) { + throw new Error("The item was already sold out"); + } + + // 2단계: 유저 정보를 업데이트합니다. + await updateEventStatus(req.userOid, { + ticket1Delta: randomItem.itemType === 1 ? 1 : 0, + ticket2Delta: randomItem.itemType === 2 ? 1 : 0, + }); + // 3단계: Transaction을 추가합니다. const transaction = new transactionModel({ type: "use", amount: 0, userId: req.userOid, item: randomItem._id, itemType: randomItem.itemType, - comment: `랜덤박스에서 ${randomItem.name} 획득 - 0개 차감`, + comment: `랜덤 박스에서 "${randomItem.name}" 1개를 획득했습니다.`, }); await transaction.save(); return newRandomItem; } catch (err) { + logger.error(err); logger.warn( - `[RandomBox] getRandomItem(depth=${depth}) by the user(id=${req.userOid}) failed due to ${err}.` + `User ${req.userOid}'s ${depth + 1}th random box failed due to exception` ); return await getRandomItem(req, depth + 1); @@ -87,21 +118,21 @@ const listHandler = async (_, res) => { const purchaseHandler = async (req, res) => { try { + const eventStatus = await eventStatusModel.findOne({ userId: req.userOid }); + if (!eventStatus) + return res + .status(400) + .json({ error: "Items/Purchase : nonexistent eventStatus" }); + const { itemId } = req.params; const item = await itemModel.findOne({ _id: itemId }).lean(); if (!item) return res.status(400).json({ error: "Items/Purchase : invalid Item" }); - const user = await useUserCreditAmount(req.userOid); - if (!user) - return res - .status(400) - .json({ error: "Items/Purchase : invalid EventStatus" }); - // 구매 가능 조건: 크레딧이 충분하며, 재고가 남아있으며, 판매 중인 아이템이어야 합니다. if (item.isDisabled) return res.status(400).json({ error: "Items/Purchase : disabled item" }); - if (user.amount < item.price) + if (eventStatus.creditAmount < item.price) return res .status(400) .json({ error: "Items/Purchase : not enough credit" }); @@ -111,32 +142,31 @@ const purchaseHandler = async (req, res) => { .json({ error: "Items/Purchase : item out of stock" }); // 1단계: 재고를 차감합니다. - // 재고가 차감됐으나 유저 크레딧이 차감되지 않은 경우, 나중에 Transaction 기록 분석을 통해 오류 복구가 가능합니다. - // 하지만 유저 크레딧이 차감됐으나 재고가 차감되지 않은 경우, 다른 유저가 품절된 상품을 구입할 수 있게 되고, 이는 다수의 유저에게 불편을 야기할 수 있습니다. - 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) throw new Error("The item was already sold out"); - // 2단계: 유저의 크레딧을 차감합니다. - await user.update(-item.price); + // 2단계: 유저 정보를 업데이트합니다. + await updateEventStatus(req.userOid, { + creditDelta: -item.price, + ticket1Delta: item.itemType === 1 ? 1 : 0, + ticket2Delta: item.itemType === 2 ? 1 : 0, + }); // 3단계: Transaction을 추가합니다. - // Transaction은 가장 마지막에 추가해야 다른 문서와의 불일치를 감지할 수 있습니다. const transaction = new transactionModel({ type: "use", amount: item.price, userId: req.userOid, item: item._id, itemType: item.itemType, - comment: `${item.name} 구입 - ${item.price}개 차감`, + comment: `송편 ${item.price}개를 사용해 "${item.name}" 1개를 획득했습니다.`, }); await transaction.save(); diff --git a/src/lottery/services/publicNotice.js b/src/lottery/services/publicNotice.js new file mode 100644 index 00000000..e6b287fa --- /dev/null +++ b/src/lottery/services/publicNotice.js @@ -0,0 +1,103 @@ +const { transactionModel } = require("../modules/stores/mongo"); +const { eventStatusModel } = require("../modules/stores/mongo"); +const { userModel } = require("../../modules/stores/mongo"); +const { isLogin, getLoginInfo } = require("../../modules/auths/login"); +const logger = require("../../modules/logger"); +const { + publicNoticePopulateOption, +} = require("../modules/populates/transactions"); + +const getRecentPurchaceItemListHandler = async (req, res) => { + try { + const transactions = ( + await transactionModel + .find({ type: "use", itemType: 0 }) + .sort({ createAt: -1 }) + .limit(5) + .populate(publicNoticePopulateOption) + .lean() + ).map( + ({ userId, item, comment }) => + `${userId.nickname}님께서 ${item.name}을(를) ${ + comment.startsWith("송편") + ? "을(를) 구입하셨습니다." + : comment.startsWith("랜덤 박스") + ? "을(를) 뽑았습니다." + : "을(를) 획득하셨습니다." + }` + ); + res.json({ transactions }); + } catch (err) { + logger.error(err); + res.status(500).json({ + error: "PublicNotice/RecentTransactions : internal server error", + }); + } +}; + +const getTicketLeaderboardHandler = async (req, res) => { + try { + const users = await eventStatusModel + .find({ + $or: [{ ticket1Amount: { $gt: 0 } }, { ticket2Amount: { $gt: 0 } }], + }) + .lean(); + const sortedUsers = users + .map((user) => ({ + userId: user.userId.toString(), + ticket1Amount: user.ticket1Amount, + ticket2Amount: user.ticket2Amount, + weight: user.ticket1Amount + 5 * user.ticket2Amount, + })) + .sort((a, b) => -(a.weight - b.weight)); + + 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 leaderboard = await Promise.all( + sortedUsers.slice(0, 20).map(async (user) => { + const userInfo = await userModel.findOne({ _id: user.userId }).lean(); + if (!userInfo) { + 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, + }; + }) + ); + if (leaderboard.includes(null)) + return 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 }); + } catch (err) { + logger.error(err); + res + .status(500) + .json({ error: "PublicNotice/Leaderboard : internal server error" }); + } +}; + +module.exports = { + getRecentPurchaceItemListHandler, + getTicketLeaderboardHandler, +}; diff --git a/src/lottery/services/quests.js b/src/lottery/services/quests.js new file mode 100644 index 00000000..7212893f --- /dev/null +++ b/src/lottery/services/quests.js @@ -0,0 +1,46 @@ +const logger = require("../../modules/logger"); +const contracts = require("../modules/contracts/2023fall"); + +/** + * 인스타그램 스토리에 이벤트를 공유했을 때. + */ +const instagramEventShareHandler = async (req, res) => { + try { + const { userOid } = req; + const contractResult = await contracts.completeEventSharingOnInstagramQuest( + userOid, + req.timestamp + ); + res.json({ result: !!contractResult }); + } catch (err) { + logger.error(err); + res + .status(500) + .json({ error: "Quests/Instagram/ShareEvent: internal server error" }); + } +}; + +/** + * 인스타그램 스토리에 아이템 구매 내역을 공유했을 때. + */ +const instagramPurchaseShareHandler = async (req, res) => { + try { + const { userOid } = req; + const contractResult = + await contracts.completePurchaseSharingOnInstagramQuest( + userOid, + req.timestamp + ); + res.json({ result: !!contractResult }); + } catch (err) { + logger.error(err); + res + .status(500) + .json({ error: "Quests/Instagram/SharePurchase: internal server error" }); + } +}; + +module.exports = { + instagramEventShareHandler, + instagramPurchaseShareHandler, +}; diff --git a/src/lottery/services/transactions.js b/src/lottery/services/transactions.js index 2009e540..c151d81f 100644 --- a/src/lottery/services/transactions.js +++ b/src/lottery/services/transactions.js @@ -8,7 +8,7 @@ const getUserTransactionsHandler = async (req, res) => { try { // userId는 이미 Frontend에서 알고 있고, 중복되는 값이므로 제외합니다. const transactions = await transactionModel - .find({ userId: req.userOid }, "-userId -itemType -__v") + .find({ userId: req.userOid }, "-userId -__v") .populate(transactionPopulateOption) .lean(); if (transactions) diff --git a/src/modules/adminResource.js b/src/modules/adminResource.js index 655e928b..b75013b3 100644 --- a/src/modules/adminResource.js +++ b/src/modules/adminResource.js @@ -57,6 +57,8 @@ const defaultActionLogFeature = buildFeature({ }); const recordActionAfterHandler = (actions) => async (res, req, context) => { + if (!res.response) return res; + const actionsWrapper = Array.isArray(actions) ? actions : [actions]; for (const action of actionsWrapper) { if (typeof action === "string") { diff --git a/src/modules/stores/mongo.js b/src/modules/stores/mongo.js index c255d7b6..998aecdd 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/auth.js b/src/services/auth.js index bab3fc71..83b598de 100644 --- a/src/services/auth.js +++ b/src/services/auth.js @@ -91,6 +91,7 @@ const tryLogin = async (req, res, userData, redirectOrigin, redirectPath) => { } login(req, userData.sid, user.id, user._id, user.name); + res.redirect(new URL(redirectPath, redirectOrigin).href); } catch (err) { logger.error(err); diff --git a/src/services/auth.mobile.js b/src/services/auth.mobile.js index fac66032..0e537b33 100644 --- a/src/services/auth.mobile.js +++ b/src/services/auth.mobile.js @@ -36,6 +36,7 @@ const tokenLoginHandler = async (req, res) => { login(req, user.sid, user.id, user._id, user.name); req.session.isApp = true; req.session.deviceToken = deviceToken; + return res.status(200).json({ message: "success" }); } catch (e) { logger.error(e); diff --git a/src/services/notifications.js b/src/services/notifications.js index 799c8bb7..633f6739 100644 --- a/src/services/notifications.js +++ b/src/services/notifications.js @@ -4,6 +4,9 @@ const logger = require("../modules/logger"); const { registerDeviceToken, validateDeviceToken } = require("../modules/fcm"); +// 이벤트 코드입니다. +const { contracts } = require("../lottery"); + const registerDeviceTokenHandler = async (req, res) => { try { // 해당 FCM device token이 유효한지 검사합니다. @@ -104,6 +107,13 @@ const editOptionsHandler = async (req, res) => { .send("Notification/editOptions: deviceToken not found"); } + // 이벤트 코드입니다. + await contracts?.completeAdPushAgreementQuest( + req.userOid, + req.timestamp, + options.advertisement + ); + res.status(200).json(updatedNotificationOptions); } catch (err) { logger.error(err); diff --git a/src/services/rooms.js b/src/services/rooms.js index 0456d534..d4b7557e 100644 --- a/src/services/rooms.js +++ b/src/services/rooms.js @@ -11,6 +11,9 @@ const { getIsOver, } = require("../modules/populates/rooms"); +// 이벤트 코드입니다. +const { contracts } = require("../lottery"); + const createHandler = async (req, res) => { const { name, from, to, time, maxPartLength } = req.body; @@ -81,7 +84,12 @@ const createHandler = async (req, res) => { }); const roomObject = (await room.populate(roomPopulateOption)).toObject(); - return res.send(formatSettlement(roomObject)); + const roomObjectFormated = formatSettlement(roomObject); + + // 이벤트 코드입니다. + await contracts?.completeFirstRoomCreationQuest(req.userOid, req.timestamp); + + return res.send(roomObjectFormated); } catch (err) { logger.error(err); res.status(500).json({ @@ -483,6 +491,18 @@ const commitPaymentHandler = async (req, res) => { authorId: user._id, }); + // 이벤트 코드입니다. + await contracts?.completePayingQuest( + req.userOid, + req.timestamp, + roomObject + ); + await contracts?.completePayingAndSendingQuest( + req.userOid, + req.timestamp, + roomObject + ); + // 수정한 방 정보를 반환합니다. res.send(formatSettlement(roomObject, { isOver: true })); } catch (err) { @@ -549,6 +569,18 @@ const settlementHandler = async (req, res) => { authorId: user._id, }); + // 이벤트 코드입니다. + await contracts?.completeSendingQuest( + req.userOid, + req.timestamp, + roomObject + ); + await contracts?.completePayingAndSendingQuest( + req.userOid, + req.timestamp, + roomObject + ); + // 수정한 방 정보를 반환합니다. res.send(formatSettlement(roomObject, { isOver: true })); } catch (err) { diff --git a/src/services/users.js b/src/services/users.js index 77af8c7d..f4a00d6a 100644 --- a/src/services/users.js +++ b/src/services/users.js @@ -2,6 +2,9 @@ const { userModel } = require("../modules/stores/mongo"); const logger = require("../modules/logger"); const aws = require("../modules/stores/aws"); +// 이벤트 코드입니다. +const { contracts } = require("../lottery"); + const agreeOnTermsOfServiceHandler = async (req, res) => { try { let user = await userModel.findOne({ id: req.userId }); @@ -34,43 +37,54 @@ const getAgreeOnTermsOfServiceHandler = async (req, res) => { }; const editNicknameHandler = async (req, res) => { - const newNickname = req.body.nickname; + try { + const newNickname = req.body.nickname; + const result = await userModel.findOneAndUpdate( + { id: req.userId }, + { nickname: newNickname } + ); - // 닉네임을 갱신하고 결과를 반환 - await userModel - .findOneAndUpdate({ id: req.userId }, { nickname: newNickname }) - .then((result) => { - if (result) { - res - .status(200) - .send("User/editNickname : edit user nickname successful"); - } else { - res.status(400).send("User/editNickname : such user id does not exist"); - } - }) - .catch((err) => { - logger.error(err); - res.status(500).send("User/editNickname : internal server error"); - }); + if (result) { + // 이벤트 코드입니다. + await contracts?.completeNicknameChangingQuest( + req.userOid, + req.timestamp + ); + + res.status(200).send("User/editNickname : edit user nickname successful"); + } else { + res.status(400).send("User/editNickname : such user id does not exist"); + } + } catch (err) { + logger.error(err); + res.status(500).send("User/editNickname : internal server error"); + } }; const editAccountHandler = async (req, res) => { - const newAccount = req.body.account; + try { + const newAccount = req.body.account; + const result = await userModel.findOneAndUpdate( + { id: req.userId }, + { account: newAccount } + ); - // 계좌번호를 갱신하고 결과를 반환 - await userModel - .findOneAndUpdate({ id: req.userId }, { account: newAccount }) - .then((result) => { - if (result) { - res.status(200).send("User/editAccount : edit user account successful"); - } else { - res.status(400).send("User/editAccount : such user id does not exist"); - } - }) - .catch((err) => { - logger.error(err); - res.status(500).send("User/editAccount : internal server error"); - }); + if (result) { + // 이벤트 코드입니다. + await contracts?.completeAccountChangingQuest( + req.userOid, + req.timestamp, + newAccount + ); + + res.status(200).send("User/editAccount : edit user account successful"); + } else { + res.status(400).send("User/editAccount : such user id does not exist"); + } + } catch (err) { + logger.error(err); + res.status(500).send("User/editAccount : internal server error"); + } }; const editProfileImgGetPUrlHandler = async (req, res) => {