Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#467 어뷰징 대응 자동화 #472

Merged
merged 17 commits into from
Feb 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading