빌드 성공

This commit is contained in:
mineseo-kim 2026-04-01 17:41:09 +09:00
parent 80e104a9f4
commit c10822ea87
3 changed files with 329 additions and 0 deletions

226
src/core/command.ts Normal file
View File

@ -0,0 +1,226 @@
import {
SlashCommandBuilder,
SlashCommandOptionsOnlyBuilder,
SlashCommandSubcommandsOnlyBuilder,
ChatInputCommandInteraction,
} from 'discord.js';
/** Values returned from {@link SlashCommandBuilder} after chaining options or subcommands only. */
export type SlashCommandData =
| SlashCommandBuilder
| SlashCommandOptionsOnlyBuilder
| SlashCommandSubcommandsOnlyBuilder;
import { prisma } from '../database';
import { SupportedLocale } from '../i18n';
import { SubscriptionTier } from '@prisma/client';
/**
* · .
*
* - 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;
}
// Payment flags were replaced by subscription tiers.
// A guild is considered "paid" if it has an owner whose subscription tier is above FREE.
const ownership = await prisma.guildOwnership.findUnique({
where: { guildId },
include: { owner: true },
});
const paid = ownership?.owner?.tier != null && ownership.owner.tier !== SubscriptionTier.FREE;
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: SlashCommandData;
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: SlashCommandData | 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(): SlashCommandData {
if (!this.cachedData) {
this.cachedData = this.define();
}
return this.cachedData;
}
/**
* (, , , , , `setDefaultMemberPermissions` )
* `SlashCommandBuilder` .
*/
protected abstract define(): SlashCommandData;
/**
* .
*
* `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,
};
}
}

49
src/core/db.ts Normal file
View File

@ -0,0 +1,49 @@
import { Prisma, PrismaClient } from '@prisma/client';
import { prisma } from '../database';
export type DbClient = PrismaClient;
export type TxClient = Prisma.TransactionClient;
function isRootClient(client: DbClient | TxClient): client is DbClient {
return typeof (client as DbClient).$transaction === 'function';
}
/**
* Runs `fn` inside a DB transaction.
*
* - If `fn` throws/rejects, **all operations are rolled back**.
* - Prefer this over array-based transactions when you need multiple steps
* (reads + conditional writes) to be atomic.
*/
export async function transaction<T>(
fn: (tx: TxClient) => Promise<T>,
options?: {
maxWait?: number;
timeout?: number;
isolationLevel?: Prisma.TransactionIsolationLevel;
},
): Promise<T> {
return prisma.$transaction(fn, options);
}
/**
* Utility to support both "already in a transaction" and "start a new one".
*
* If `client` is a root PrismaClient, it starts a transaction.
* If `client` is already a TransactionClient, it reuses it.
*/
export async function withTransaction<T>(
client: DbClient | TxClient,
fn: (tx: TxClient) => Promise<T>,
options?: {
maxWait?: number;
timeout?: number;
isolationLevel?: Prisma.TransactionIsolationLevel;
},
): Promise<T> {
if (isRootClient(client)) {
return client.$transaction(fn, options);
}
return fn(client);
}

54
tests/core/db.test.ts Normal file
View File

@ -0,0 +1,54 @@
jest.mock('../../src/database', () => {
const tx = { __tx: true };
const prisma = {
$transaction: jest.fn(async (fn: (tx: unknown) => Promise<unknown>) => fn(tx)),
};
return { prisma };
});
import { prisma } from '../../src/database';
import { transaction, withTransaction } from '../../src/core/db';
describe('core/db transaction helpers', () => {
beforeEach(() => {
(prisma.$transaction as jest.Mock).mockClear();
});
test('transaction() executes callback via prisma.$transaction', async () => {
const result = await transaction(async (_tx) => {
return 123;
});
expect(result).toBe(123);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
test('transaction() propagates error (rollback is handled by Prisma)', async () => {
await expect(
transaction(async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
test('withTransaction() starts a new transaction for root client', async () => {
const rootClient = prisma as unknown as { $transaction: (fn: any) => Promise<any> };
const result = await withTransaction(rootClient as any, async (_tx) => 'ok');
expect(result).toBe('ok');
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
test('withTransaction() reuses existing tx client (does not nest)', async () => {
const txClient = {} as any; // does not have $transaction
const result = await withTransaction(txClient, async (_tx) => 'ok');
expect(result).toBe('ok');
expect(prisma.$transaction).toHaveBeenCalledTimes(0);
});
});