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