Skip to content

Commit

Permalink
Merge pull request #472 from sparcs-kaist/#467-add-anti-abusing-system
Browse files Browse the repository at this point in the history
#467 어뷰징 대응 자동화
  • Loading branch information
kmc7468 authored Feb 22, 2024
2 parents c6e617b + 77ddcbb commit 8cad748
Show file tree
Hide file tree
Showing 8 changed files with 472 additions and 11 deletions.
3 changes: 3 additions & 0 deletions src/lottery/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 검증
Expand Down
59 changes: 59 additions & 0 deletions src/lottery/modules/slackNotification.js
Original file line number Diff line number Diff line change
@@ -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,
};
230 changes: 230 additions & 0 deletions src/lottery/schedules/detectAbusingUsers.js
Original file line number Diff line number Diff line change
@@ -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");
}
};
7 changes: 7 additions & 0 deletions src/lottery/schedules/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const cron = require("node-cron");

const registerSchedules = () => {
cron.schedule("0 4 * * *", require("./detectAbusingUsers"));
};

module.exports = registerSchedules;
43 changes: 34 additions & 9 deletions src/modules/slackNotification.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" };

Expand All @@ -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,
};
13 changes: 13 additions & 0 deletions src/routes/rooms.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading

0 comments on commit 8cad748

Please sign in to comment.