diff --git a/src/lottery/index.js b/src/lottery/index.js index ac00ffe2..d485dfe1 100644 --- a/src/lottery/index.js +++ b/src/lottery/index.js @@ -18,6 +18,9 @@ const contracts = eventConfig && require("./modules/contracts"); // [Routes] 기존 docs 라우터의 docs extend eventConfig && require("./routes/docs")(); +// [Schedule] 스케줄러 시작 +eventConfig && require("./schedules")(); + const lotteryRouter = express.Router(); // [Middleware] 모든 API 요청에 대하여 origin 검증 diff --git a/src/lottery/modules/slackNotification.js b/src/lottery/modules/slackNotification.js new file mode 100644 index 00000000..603c708d --- /dev/null +++ b/src/lottery/modules/slackNotification.js @@ -0,0 +1,59 @@ +const { sendTextToReportChannel } = require("../../modules/slackNotification"); + +const generateContent = (name, userIds, roomIds = []) => { + if (userIds.length === 0) return ""; + + const strUserIds = userIds.join(", "); + const strRoomIds = + roomIds.length > 0 ? ` (관련된 방: ${roomIds.join(", ")})` : ""; + return `\n ${name}: ${strUserIds}${strRoomIds}`; +}; + +const notifyAbuseDetectionResultToReportChannel = ( + abusingUserIds, + reportedUserIds, + rooms, + multiplePartUserIds, + lessChatRooms, + lessChatUserIds +) => { + const title = `어제의 활동을 기준으로, ${abusingUserIds.length}명의 어뷰징 의심 사용자를 감지하였습니다.`; + + if (abusingUserIds.length === 0) { + sendTextToReportChannel(title); + return; + } + + const strAbusingUsers = generateContent( + "전체 어뷰징 의심 사용자", + abusingUserIds + ); + const strReportedUsers = generateContent( + '"기타 사유"로 신고받은 사용자', + reportedUserIds + ); + const strMultiplePartUsers = generateContent( + "하루에 탑승 기록이 많은 사용자", + multiplePartUserIds, + rooms.reduce((array, { roomIds }) => array.concat(roomIds), []) + ); + const strLessChatUsers = generateContent( + "채팅 개수가 5개 미만인 방에 속한 사용자", + lessChatUserIds, + lessChatRooms.reduce( + (array, room) => (room ? array.concat([room.roomId]) : array), + [] + ) + ); + const contents = strAbusingUsers.concat( + strReportedUsers, + strMultiplePartUsers, + strLessChatUsers + ); + + sendTextToReportChannel(`${title}\n${contents}`); +}; + +module.exports = { + notifyAbuseDetectionResultToReportChannel, +}; diff --git a/src/lottery/schedules/detectAbusingUsers.js b/src/lottery/schedules/detectAbusingUsers.js new file mode 100644 index 00000000..a0da254b --- /dev/null +++ b/src/lottery/schedules/detectAbusingUsers.js @@ -0,0 +1,230 @@ +const { eventStatusModel } = require("../modules/stores/mongo"); +const { + roomModel, + chatModel, + reportModel, +} = require("../../modules/stores/mongo"); +const { + notifyAbuseDetectionResultToReportChannel, +} = require("../modules/slackNotification"); +const logger = require("../../modules/logger"); + +const { eventConfig } = require("../../../loadenv"); +const eventPeriod = eventConfig && { + startAt: new Date(eventConfig.period.startAt), + endAt: new Date(eventConfig.period.endAt), +}; + +/** + * 매일 새벽 4시에 어뷰징 사용자를 감지하고, Slack을 통해 관리자에게 알림을 전송합니다. + * Original Idea by chlehdwon + * + * 성능면에서 상당히 죄책감이 드는 코드이지만, 새벽에 동작하니 괜찮을 것 같습니다... :( + */ + +// 두 ObjectId가 같은지 비교하는 함수 +const equalsObjectId = (a) => (b) => a.equals(b); + +// ObjectId의 배열에서 중복을 제거하는 함수 +const removeObjectIdDuplicates = (array) => { + return array.filter( + (element, index) => array.findIndex(equalsObjectId(element)) === index + ); +}; + +// 기준 1. "기타 사유"로 신고받은 사용자 +const detectReportedUsers = async (period, candidateUserIds) => { + const reports = await reportModel.aggregate([ + { + $match: { + reportedId: { $in: candidateUserIds }, + type: "etc-reason", + time: { $gte: period.startAt, $lt: period.endAt }, + }, + }, + ]); + const reportedUserIds = removeObjectIdDuplicates( + reports.map((report) => report.reportedId) + ); + + return { reports, reportedUserIds }; +}; + +// 기준 2. 하루에 탑승 기록이 많은 사용자 +const detectMultiplePartUsers = async (period, candidateUserIds) => { + const rooms = await roomModel.aggregate([ + { + $match: { + part: { $elemMatch: { user: { $in: candidateUserIds } } }, // 방 참여자 중 후보자가 존재 + "part.1": { $exists: true }, // 방 참여자가 2명 이상 + time: { $gte: period.startAt, $lt: period.endAt }, + settlementTotal: { $gt: 0 }, + }, + }, + { + $group: { + _id: { + $dateToString: { + date: "$time", + format: "%Y-%m-%d", + timezone: "+09:00", + }, + }, + roomIds: { $push: "$_id" }, + users: { $push: "$part.user" }, + }, + }, // 후보 방들을 날짜별로 그룹화 + { + $project: { + roomIds: true, + users: { + $reduce: { + input: "$users", + initialValue: [], + in: { $concatArrays: ["$$value", "$$this"] }, + }, + }, + }, + }, // 날짜별로 방 참여자들의 목록을 병합 + ]); + const multiplePartUserIds = removeObjectIdDuplicates( + rooms.reduce( + (array, { users }) => + array.concat( + removeObjectIdDuplicates(users).filter( + (userId) => + users.findIndex(equalsObjectId(userId)) !== + users.findLastIndex(equalsObjectId(userId)) // 두 값이 다르면 중복된 값이 존재 + ) + ), + [] + ) + ); + + return { rooms, multiplePartUserIds }; +}; + +// 기준 3. 채팅 개수가 5개 미만인 방에 속한 사용자 +const detectLessChatUsers = async (period, candidateUserIds) => { + const chats = await chatModel.aggregate([ + { + $match: { + time: { $gte: period.startAt, $lt: period.endAt }, + }, + }, + { + $group: { + _id: "$roomId", + count: { + $sum: { + $cond: [{ $eq: ["$type", "text"] }, 1, 0], // type이 text인 경우만 count + }, + }, + }, + }, // 채팅들을 방별로 그룹화 + { + $match: { + count: { $lt: 5 }, + }, + }, + ]); + const lessChatRooms = await Promise.all( + chats.map(async ({ _id: roomId, count }) => { + const room = await roomModel.findById(roomId).lean(); + if ( + period.startAt > room.time || + period.endAt <= room.time || + room.settlementTotal === 0 + ) + return null; + + const parts = room.part + .map((part) => part.user) + .filter((userId) => candidateUserIds.some(equalsObjectId(userId))); + if (parts.length === 0) return null; + + return { + roomId, + parts, + }; + }) + ); + const lessChatUserIds = removeObjectIdDuplicates( + lessChatRooms.reduce( + (array, day) => (day ? array.concat(day.parts) : array), + [] + ) + ); + + return { lessChatRooms, lessChatUserIds }; +}; + +module.exports = async () => { + try { + // 오늘 자정(0시) + const todayMidnight = new Date(); + todayMidnight.setHours(0, 0, 0, 0); + + // 어제 자정 + const yesterdayMidnight = new Date(); + yesterdayMidnight.setDate(yesterdayMidnight.getDate() - 1); + yesterdayMidnight.setHours(0, 0, 0, 0); + + // 이벤트 기간이 아니면 종료 + if ( + !eventPeriod || + yesterdayMidnight >= eventPeriod.endAt || + todayMidnight <= eventPeriod.startAt + ) + return; + + logger.info("Abusing user detection started"); + + // 어제 있었던 활동을 기준으로 감지 + const period = { + startAt: yesterdayMidnight, + endAt: todayMidnight, + }; + + const candidateUsers = await eventStatusModel.find({}, "userId").lean(); + const candidateUserIds = candidateUsers.map((user) => user.userId); + + // 기준 1 ~ 기준 3에 각각 해당되는 사용자 목록 + const { reportedUserIds } = await detectReportedUsers( + period, + candidateUserIds + ); + const { rooms, multiplePartUserIds } = await detectMultiplePartUsers( + period, + candidateUserIds + ); + const { lessChatRooms, lessChatUserIds } = await detectLessChatUsers( + period, + candidateUserIds + ); + + // 기준 1 ~ 기준 3 중 하나라도 해당되는 사용자 목록 + const abusingUserIds = removeObjectIdDuplicates( + reportedUserIds.concat(multiplePartUserIds, lessChatUserIds) + ); + + logger.info( + `Total ${abusingUserIds.length} users detected! Refer to Slack for more information` + ); + + // Slack으로 알림 전송 + notifyAbuseDetectionResultToReportChannel( + abusingUserIds, + reportedUserIds, + rooms, + multiplePartUserIds, + lessChatRooms, + lessChatUserIds + ); + + logger.info("Abusing user detection successfully finished"); + } catch (err) { + logger.error(err); + logger.error("Abusing user detection failed"); + } +}; diff --git a/src/lottery/schedules/index.js b/src/lottery/schedules/index.js new file mode 100644 index 00000000..09665fef --- /dev/null +++ b/src/lottery/schedules/index.js @@ -0,0 +1,7 @@ +const cron = require("node-cron"); + +const registerSchedules = () => { + cron.schedule("0 4 * * *", require("./detectAbusingUsers")); +}; + +module.exports = registerSchedules; diff --git a/src/modules/slackNotification.js b/src/modules/slackNotification.js index dc00e4a0..6dfbb445 100644 --- a/src/modules/slackNotification.js +++ b/src/modules/slackNotification.js @@ -2,18 +2,11 @@ const { slackWebhookUrl: slackUrl } = require("../../loadenv"); const axios = require("axios"); const logger = require("../modules/logger"); -module.exports.notifyToReportChannel = (reportUser, report) => { +const sendTextToReportChannel = (text) => { if (!slackUrl.report) return; const data = { - text: `${reportUser}님으로부터 신고가 접수되었습니다. - - 신고자 ID: ${report.creatorId} - 신고 ID: ${report.reportedId} - 방 ID: ${report.roomId ?? ""} - 사유: ${report.type} - 기타: ${report.etcDetail} - `, + text, }; const config = { "Content-Type": "application/json" }; @@ -26,3 +19,35 @@ module.exports.notifyToReportChannel = (reportUser, report) => { logger.error("Fail to send slack webhook", err); }); }; + +const notifyReportToReportChannel = (reportUser, report) => { + sendTextToReportChannel( + `${reportUser}님으로부터 신고가 접수되었습니다. + + 신고자 ID: ${report.creatorId} + 신고 ID: ${report.reportedId} + 방 ID: ${report.roomId ?? ""} + 사유: ${report.type} + 기타: ${report.etcDetail}` + ); +}; + +const notifyRoomCreationAbuseToReportChannel = ( + abusingUser, + { from, to, time, maxPartLength } +) => { + sendTextToReportChannel( + `${abusingUser}님이 어뷰징이 의심되는 방을 생성하려고 시도했습니다. + + 출발지: ${from} + 도착지: ${to} + 출발 시간: ${time} + 최대 참여 가능 인원: ${maxPartLength}명` + ); +}; + +module.exports = { + sendTextToReportChannel, + notifyReportToReportChannel, + notifyRoomCreationAbuseToReportChannel, +}; diff --git a/src/routes/rooms.js b/src/routes/rooms.js index be1c3f59..6334136d 100644 --- a/src/routes/rooms.js +++ b/src/routes/rooms.js @@ -55,6 +55,19 @@ router.post( roomHandlers.createHandler ); +// 방을 생성하기 전, 생성하고자 하는 방이 실제로 택시 탑승의 목적성을 갖고 있는지 예측한다. +router.post( + "/create/test", + [ + body("from").isMongoId(), + body("to").isMongoId(), + body("time").isISO8601(), + body("maxPartLength").isInt({ min: 2, max: 4 }), + ], + validator, + roomHandlers.createTestHandler +); + // 새로운 사용자를 방에 참여시킨다. // FIXME: req.body.users 검증할 때 SSO ID 규칙 반영하기 router.post( diff --git a/src/services/reports.js b/src/services/reports.js index 0451b0cc..eb6034ed 100644 --- a/src/services/reports.js +++ b/src/services/reports.js @@ -7,7 +7,7 @@ const { reportPopulateOption } = require("../modules/populates/reports"); const { sendReportEmail } = require("../modules/stores/aws"); const logger = require("../modules/logger"); const emailPage = require("../views/emailNoSettlementPage"); -const { notifyToReportChannel } = require("../modules/slackNotification"); +const { notifyReportToReportChannel } = require("../modules/slackNotification"); const createHandler = async (req, res) => { try { @@ -40,7 +40,7 @@ const createHandler = async (req, res) => { await report.save(); - notifyToReportChannel(user.nickname, report); + notifyReportToReportChannel(user.nickname, report); if (report.type === "no-settlement") { const emailRoomName = room ? room.name : ""; diff --git a/src/services/rooms.js b/src/services/rooms.js index fbdf2f31..53453783 100644 --- a/src/services/rooms.js +++ b/src/services/rooms.js @@ -10,8 +10,16 @@ const { formatSettlement, getIsOver, } = require("../modules/populates/rooms"); +const { + notifyRoomCreationAbuseToReportChannel, +} = require("../modules/slackNotification"); // 이벤트 코드입니다. +const { eventConfig } = require("../../loadenv"); +const eventPeriod = eventConfig && { + startAt: new Date(eventConfig.period.startAt), + endAt: new Date(eventConfig.period.endAt), +}; const { contracts } = require("../lottery"); const createHandler = async (req, res) => { @@ -106,6 +114,121 @@ const createHandler = async (req, res) => { } }; +const checkIsAbusing = ( + { from, to, time, maxPartLength }, + countRecentlyMadeRooms, + candidateRooms +) => { + /** + * 방을 생성하였을 때, 다음 조건 중 하나라도 만족하게 되면 어뷰징 가능성이 있다고 판단합니다. + * 1. 참여한 방 중, 생성하려는 방의 출발 시간 앞 뒤 12시간 내에 출발하는 방이 3개 이상인 경우 + * 2. 참여한 방 중, 생성하려는 방의 출발 시간 앞 뒤 12시간 내에 출발하는 방이 2개이고, 다음 조건 중 하나 이상을 만족하는 경우 + * a. 두 방의 출발 시간 간격이 1시간 이하인 경우 + * b. 두 방의 출발 시간 간격이 1시간 초과이고, 다음 조건 중 하나 이상을 만족하는 경우 + * i. 두 방의 출발지가 같은 경우 + * ii. 두 방의 목적지가 같은 경우 + * iii. 먼저 출발하는 방의 목적지와 나중에 출발하는 방의 출발지가 다른 경우 + * iv. 두 방의 최대 탑승 가능 인원이 모두 2명인 경우 + * 3. 최근 24시간 내에 생성한 방이 4개 이상인 경우 + */ + + if (countRecentlyMadeRooms + 1 >= 4) return true; // 조건 3 + + if (candidateRooms.length + 1 >= 3) return true; // 조건 1 + if (candidateRooms.length + 1 < 2) return false; // 조건 2의 여집합 + + let firstRoom = { + from: candidateRooms[0].from.toString(), + to: candidateRooms[0].to.toString(), + time: candidateRooms[0].time, + maxPartLength: candidateRooms[0].maxPartLength, + }; + let secondRoom = { + from, + to, + time: new Date(time), + maxPartLength, + }; + if (secondRoom.time < firstRoom.time) { + [firstRoom, secondRoom] = [secondRoom, firstRoom]; + } + + if (secondRoom.time - firstRoom.time <= 3600000) return true; // 조건 2-a + if ( + firstRoom.from === secondRoom.from || + firstRoom.to === secondRoom.to || + firstRoom.to !== secondRoom.from + ) + return true; // 조건 2-b-i, 2-b-ii, 2-b-iii + if (firstRoom.maxPartLength === 2 && secondRoom.maxPartLength === 2) + return true; // 조건 2-b-iv + + return false; +}; + +const createTestHandler = async (req, res) => { + // 이 Handler에서는 Parameter에 대해 추가적인 Validation을 하지 않습니다. + const { time } = req.body; + + try { + // 이벤트 코드입니다. + if ( + !eventPeriod || + req.timestamp >= eventPeriod.endAt || + req.timestamp < eventPeriod.startAt + ) + return res.json({ result: true }); + + const countRecentlyMadeRooms = await roomModel.countDocuments({ + madeat: { $gte: new Date(req.timestamp - 86400000) }, // 밀리초 단위로 24시간을 나타냅니다. + "part.0.user": req.userOid, // 방 최초 생성자를 저장하는 필드가 없으므로, 첫 번째 참여자를 생성자로 간주합니다. + }); + if (!countRecentlyMadeRooms && countRecentlyMadeRooms !== 0) + return res + .status(500) + .json({ error: "Rooms/create/test : internal server error" }); + + const dateTime = new Date(time); + const candidateRooms = await roomModel + .find( + { + time: { + $gte: new Date(dateTime.getTime() - 43200000), + $lte: new Date(dateTime.getTime() + 43200000), + }, + part: { $elemMatch: { user: req.userOid } }, + }, + "from to time maxPartLength" + ) + .limit(2) + .lean(); + if (!candidateRooms) + return res + .status(500) + .json({ error: "Rooms/create/test : internal server error" }); + + const isAbusing = checkIsAbusing( + req.body, + countRecentlyMadeRooms, + candidateRooms + ); + if (isAbusing) { + const user = await userModel.findById(req.userOid).lean(); + notifyRoomCreationAbuseToReportChannel( + user?.nickname ?? req.userOid, + req.body + ); + } + + return res.json({ result: !isAbusing }); + } catch (err) { + logger.error(err); + res.status(500).json({ + error: "Rooms/create/test : internal server error", + }); + } +}; + const publicInfoHandler = async (req, res) => { try { const roomObject = await roomModel @@ -681,6 +804,7 @@ module.exports = { publicInfoHandler, infoHandler, createHandler, + createTestHandler, joinHandler, abortHandler, searchHandler,