From c1d18d7d8fa7bfbf2dd5add0860ffc949c94edcd Mon Sep 17 00:00:00 2001 From: mineseo-kim Date: Tue, 31 Mar 2026 10:13:38 +0900 Subject: [PATCH] =?UTF-8?q?Service=20=EA=B3=A0=EB=8F=84=ED=99=94=20?= =?UTF-8?q?=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 난 한국어 커밋 메세지가 좋아 --- .../migration.sql | 9 + prisma/schema.prisma | 10 + src/service/command.ts | 212 ++++++++++++++++++ src/service/test.ts | 83 +++++++ 4 files changed, 314 insertions(+) create mode 100644 prisma/migrations/20260331120000_add_guild_payment/migration.sql create mode 100644 src/service/command.ts create mode 100644 src/service/test.ts diff --git a/prisma/migrations/20260331120000_add_guild_payment/migration.sql b/prisma/migrations/20260331120000_add_guild_payment/migration.sql new file mode 100644 index 0000000..6920b58 --- /dev/null +++ b/prisma/migrations/20260331120000_add_guild_payment/migration.sql @@ -0,0 +1,9 @@ +-- CreateTable +CREATE TABLE "guild_payment" ( + "id" TEXT NOT NULL, + "music" BOOLEAN NOT NULL DEFAULT false, + "minigame" BOOLEAN NOT NULL DEFAULT false, + "broadcast" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "guild_payment_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cc790cd..8fcb7f2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -140,3 +140,13 @@ enum EventStatus { CANCELLED COMPLETED } + +/// 길드별 유료 기능(특성) 플래그. 행이 없거나 해당 컬럼이 false면 미결제로 간주합니다. +model GuildPayment { + id String @id + music Boolean @default(false) + minigame Boolean @default(false) + broadcast Boolean @default(false) + + @@map("guild_payment") +} diff --git a/src/service/command.ts b/src/service/command.ts new file mode 100644 index 0000000..13a0839 --- /dev/null +++ b/src/service/command.ts @@ -0,0 +1,212 @@ +import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js'; +import { prisma } from '../database'; +import { SupportedLocale } from '../i18n'; + +/** + * 명령의 도메인·특성 구분입니다. + * + * - 같은 특성끼리 묶어 help 표시, 권한 정책, 기능 플래그, 통계 등에 활용할 수 있습니다. + * - `Command`를 쓰지 않는 기존 명령 모듈에는 값이 없을 수 있습니다. + */ +export enum CommandTrait { + /** 음악 재생·대기열 등 */ + Music = 'music', + /** 미니게임 */ + Minigame = 'minigame', + /** 방송 연동·알림 등 */ + Broadcast = 'broadcast', + /** 위에 해당하지 않는 일반 명령(설정·감사·음성 등) */ + General = 'general', +} + +/** {@link CommandTrait.General}을 제외한 특성은 `guild_payment` 플래그가 필요합니다. */ +export function traitRequiresPayment(trait: CommandTrait): boolean { + return ( + trait === CommandTrait.Music || trait === CommandTrait.Minigame || trait === CommandTrait.Broadcast + ); +} + +/** + * 유료 특성 명령에 대해 길드 결제 플래그를 확인합니다. + * + * @returns 통과 시 `true`. 이미 `reply`로 응답한 경우 `false`. + */ +export async function ensureGuildPaidForTrait( + interaction: ChatInputCommandInteraction, + trait: CommandTrait, +): Promise { + if (!traitRequiresPayment(trait)) { + return true; + } + + const guildId = interaction.guildId; + if (!guildId) { + await interaction.reply({ + content: '이 명령은 서버에서만 사용할 수 있습니다.', + ephemeral: true, + }); + return false; + } + + const row = await prisma.guildPayment.findUnique({ where: { id: guildId } }); + const paid = + row != null && + ((trait === CommandTrait.Music && row.music) || + (trait === CommandTrait.Minigame && row.minigame) || + (trait === CommandTrait.Broadcast && row.broadcast)); + + if (!paid) { + await interaction.reply({ + content: '결제가 되지않았습니다', + ephemeral: true, + }); + return false; + } + + return true; +} + +/** + * 슬래시 명령을 `CommandLoader`에 넘길 때 쓰는 모듈 형태입니다. + * + * `src/handlers/CommandLoader.ts`는 각 명령 파일의 `default` export가 + * `data`(슬래시 정의)와 `execute`(실행 함수) 속성을 가지는지 검사한 뒤, + * `client.commands.set(command.data.name, command)`로 등록합니다. + * 따라서 이 타입은 디스코드에 올리는 빌더와, 실제 처리 진입점을 한 묶음으로 맞춥니다. + * + * `trait`은 {@link Command.toModule}을 통해 붙이며, 특성 필터링 시 사용합니다. + */ +export type CommandModule = { + data: SlashCommandBuilder; + execute: (interaction: ChatInputCommandInteraction, locale: SupportedLocale) => Promise; + trait?: CommandTrait; +}; + +/** + * 슬래시 명령용 추상 베이스 클래스입니다. + * + * **사용 흐름** + * 1. 이 클래스를 상속한 뒤 {@link trait}에 {@link CommandTrait} 중 하나를 지정합니다. + * 2. {@link define}에서 `SlashCommandBuilder`로 이름·설명·옵션·권한 등을 구성합니다. + * 3. {@link handle}에서 실제 비즈니스 로직(답장·DB·서비스 호출)을 작성합니다. + * 4. `export default new MyCommand().toModule()`처럼 `toModule()` 결과를 기본 내보내기 하면 + * 기존 `commands/*.ts`와 동일하게 로더에 잡힙니다(`trait` 메타데이터 포함). + * + * **실행 순서** (`interactionCreate` → 본 클래스의 `execute`) + * 1. `guildOnly === true`이면 DM 등 길드가 아닌 경우 즉시 안내 메시지 후 종료합니다. + * 2. {@link CommandTrait.Music} / {@link CommandTrait.Minigame} / {@link CommandTrait.Broadcast}인 경우 + * `guild_payment` 테이블을 조회해 해당 플래그가 `true`일 때만 진행합니다. (행 없음·`false` → 「결제가 되지않았습니다」) + * 3. {@link beforeHandle}이 `false`를 반환하면(이미 응답을 보낸 경우 등) {@link handle}은 호출되지 않습니다. + * 4. 그렇지 않으면 {@link handle}을 실행합니다. + * + * `events/interactionCreate.ts`는 로케일을 resolve한 뒤 `command.execute(interaction, locale)`만 호출하므로, + * 명령 파일마다 반복되던 길드-only 같은 공통 처리는 이 클래스에 모을 수 있습니다. + */ +export abstract class Command { + private cachedData: SlashCommandBuilder | null = null; + + /** + * 이 명령의 도메인 특성입니다. 서브클래스에서 반드시 한 값으로 고정하세요. + * + * 예: 음악 명령 → {@link CommandTrait.Music}, 미니게임 → {@link CommandTrait.Minigame}, + * 방송 → {@link CommandTrait.Broadcast}, 그 외 → {@link CommandTrait.General}. + */ + protected abstract readonly trait: CommandTrait; + + /** + * `true`이면 **서버(길드) 안에서만** 명령이 동작합니다. + * + * DM이나 길드 컨텍스트가 없는 상호작용에서는 {@link handle}에 들어가기 전에 + * 영문 안내를 다른 사용자에게는 보이지 않는(ephemeral) 형태로 보내고 return 합니다. + * 서버 전용 명령(채널·역할·길드 설정 등)에 켜 두면 됩니다. + */ + protected guildOnly = false; + + /** + * 디스코드에 등록할 슬래시 명령 빌더입니다. + * + * 최초 접근 시 {@link define}을 한 번만 호출해 결과를 캐시합니다. + * 같은 인스턴스에서 `data`를 여러 번 읽어도 빌더는 한 번만 만들어집니다. + */ + get data(): SlashCommandBuilder { + if (!this.cachedData) { + this.cachedData = this.define(); + } + return this.cachedData; + } + + /** + * 슬래시 명령의 정의(이름, 설명, 로컬라이즈, 서브커맨드, 옵션, `setDefaultMemberPermissions` 등)를 + * `SlashCommandBuilder`로 구성해 반환합니다. + */ + protected abstract define(): SlashCommandBuilder; + + /** + * 공통 가드를 통과한 뒤 실행되는 본 처리입니다. + * + * `interaction`은 채팅 입력 슬래시이고, `locale`은 사용자/길드 설정을 반영해 resolve된 값입니다. + */ + protected abstract handle( + interaction: ChatInputCommandInteraction, + locale: SupportedLocale, + ): Promise; + + /** + * {@link handle} 직전에 한 번 호출되는 선택적 훅입니다. + * + * 권한 검사, rate limit, 잘못된 옵션 조합에 대한 조기 응답 등 **여러 명령에서 공통으로 쓸 선행 로직**을 + * 넣기 좋습니다. + * + * @returns `true`이면 그대로 {@link handle}으로 진행합니다. + * `false`이면 **이미 `reply`/`deferReply` 등으로 응답을 끝낸 것으로 간주**하고 + * `handle`은 호출하지 않습니다. + */ + protected async beforeHandle( + _interaction: ChatInputCommandInteraction, + _locale: SupportedLocale, + ): Promise { + return true; + } + + /** + * 로더/디스코드가 호출하는 진입점입니다. + * + * 길드-only 검사 → 유료 특성 시 {@link ensureGuildPaidForTrait} → `beforeHandle` → `handle` 순으로 위임합니다. + * 일반적으로 서브클래스에서 이 메서드를 직접 오버라이드할 필요는 없습니다. + */ + async execute( + interaction: ChatInputCommandInteraction, + locale: SupportedLocale, + ): Promise { + if (this.guildOnly && !interaction.inGuild()) { + await interaction.reply({ + content: 'This command can only be used in a server.', + ephemeral: true, + }); + return; + } + + if (!(await ensureGuildPaidForTrait(interaction, this.trait))) { + return; + } + + if (!(await this.beforeHandle(interaction, locale))) { + return; + } + + await this.handle(interaction, locale); + } + + /** + * `CommandModule` 형태로 묶어 `default export`에 넘깁니다. + * + * `execute`는 이 인스턴스에 바인딩되므로, 서브클래스에서 `this`를 쓰는 메서드도 그대로 동작합니다. + */ + toModule(): CommandModule { + return { + data: this.data, + execute: (interaction, locale) => this.execute(interaction, locale), + trait: this.trait, + }; + } +} diff --git a/src/service/test.ts b/src/service/test.ts new file mode 100644 index 0000000..8512a53 --- /dev/null +++ b/src/service/test.ts @@ -0,0 +1,83 @@ +/** + * `Command` 사용 예시 (참고용). + * + * 이 파일은 `handlers/CommandLoader`가 읽는 `src/commands/` 밖에 있어서 + * 디스코드에 자동 등록되지 않습니다. 실제 명령으로 쓰려면 이 내용을 + * `src/commands/your-command.ts`로 옮기고 `setName`을 고유 이름으로 바꾸세요. + * + * 특성은 {@link CommandTrait.Music}이라 `guild_payment.music === true`일 때만 + * 본 처리까지 진행됩니다(미결제 시 「결제가 되지않았습니다」). + */ + +import { + SlashCommandBuilder, + ChatInputCommandInteraction, + SlashCommandStringOption, +} from 'discord.js'; +import { Command, CommandTrait } from './command'; +import { SupportedLocale } from '../i18n'; + +class ExampleSlashCommand extends Command { + /** 음악 유료 특성 — DB `guild_payment.music` 플래그를 검사합니다. */ + protected readonly trait = CommandTrait.Music; + + /** 길드에서만 쓰도록 기본 가드 사용 */ + protected guildOnly = true; + + protected define() { + return ( + new SlashCommandBuilder() + .setName('command_usage_demo') + .setDescription('Example: Command base class usage (not registered from this path).') + .setDescriptionLocalizations({ + ko: '예시: Command 베이스 클래스 사용법 (이 경로에서는 등록되지 않음).', + }) + // 서브커맨드/옵션은 기존과 같이 붙이면 됩니다. + .addStringOption((option: SlashCommandStringOption) => + option + .setName('message') + .setDescription('Echo text') + .setRequired(false), + ) + ); + } + + /** + * 공통 선행 검사가 필요하면 `beforeHandle`을 오버라이드합니다. + * `false`를 반환하면 이미 응답을 보낸 뒤이므로 `handle`은 실행되지 않습니다. + */ + protected async beforeHandle( + interaction: ChatInputCommandInteraction, + _locale: SupportedLocale, + ): Promise { + const msg = interaction.options.getString('message'); + if (msg === 'block') { + await interaction.reply({ content: 'Blocked by beforeHandle.', ephemeral: true }); + return false; + } + return true; + } + + protected async handle( + interaction: ChatInputCommandInteraction, + locale: SupportedLocale, + ): Promise { + const message = interaction.options.getString('message'); + const guildName = interaction.guild?.name ?? 'unknown'; + const line = + message != null && message.length > 0 + ? `[${locale}] **${guildName}** — ${message}` + : `[${locale}] **${guildName}** — (no message option)`; + + await interaction.reply({ content: line, ephemeral: true }); + } +} + +/** + * `commands/*.ts`에서 쓰는 것과 동일한 내보내기 형태: + * + * ```ts + * export default new ExampleSlashCommand().toModule(); + * ``` + */ +export default new ExampleSlashCommand().toModule();