빌드 성공
This commit is contained in:
parent
80e104a9f4
commit
c10822ea87
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue