Service 고도화 작업

난 한국어 커밋 메세지가 좋아
This commit is contained in:
mineseo-kim 2026-03-31 10:13:38 +09:00
parent 1f91bfb9bf
commit c1d18d7d8f
4 changed files with 314 additions and 0 deletions

View File

@ -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")
);

View File

@ -140,3 +140,13 @@ enum EventStatus {
CANCELLED CANCELLED
COMPLETED COMPLETED
} }
/// 길드별 유료 기능(특성) 플래그. 행이 없거나 해당 컬럼이 false면 미결제로 간주합니다.
model GuildPayment {
id String @id
music Boolean @default(false)
minigame Boolean @default(false)
broadcast Boolean @default(false)
@@map("guild_payment")
}

212
src/service/command.ts Normal file
View File

@ -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<boolean> {
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<void>;
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<void>;
/**
* {@link handle} .
*
* , rate limit, ** **
* .
*
* @returns `true` {@link handle} .
* `false` ** `reply`/`deferReply` **
* `handle` .
*/
protected async beforeHandle(
_interaction: ChatInputCommandInteraction,
_locale: SupportedLocale,
): Promise<boolean> {
return true;
}
/**
* / .
*
* -only {@link ensureGuildPaidForTrait} `beforeHandle` `handle` .
* .
*/
async execute(
interaction: ChatInputCommandInteraction,
locale: SupportedLocale,
): Promise<void> {
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,
};
}
}

83
src/service/test.ts Normal file
View File

@ -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<boolean> {
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<void> {
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();