Skip to content
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
9 changes: 8 additions & 1 deletion .github/workflows/api-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,17 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max

migrate:
name: DB Migrate
needs: ci
uses: ./.github/workflows/db-migrate.yml
secrets:
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}

deploy:
name: Deploy to API Server
runs-on: ubuntu-latest
needs: build-and-push
needs: [build-and-push, migrate]

steps:
- name: Checkout Repository
Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/batch-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,17 @@ jobs:
cache-from: type=gha,scope=batch
cache-to: type=gha,mode=max,scope=batch

migrate:
name: DB Migrate
needs: ci
uses: ./.github/workflows/db-migrate.yml
secrets:
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}

deploy:
name: Deploy to API Server
runs-on: ubuntu-latest
needs: build-and-push
needs: [build-and-push, migrate]

steps:
- name: Checkout Repository
Expand Down
41 changes: 41 additions & 0 deletions .github/workflows/db-migrate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: DB Migrate

# API/Batch CD에서 배포 전에 호출한다. 마이그레이션만 바꾼 경우에는 수동 실행(workflow_dispatch)한다.
on:
workflow_call:
secrets:
SUPABASE_DB_URL:
required: true

workflow_dispatch:

permissions:
contents: read

jobs:
migrate:
name: Apply Supabase Migrations
runs-on: ubuntu-latest
# API/Batch CD가 동시에 호출해도 순서대로 적용한다 (이미 적용된 마이그레이션은 건너뜀)
concurrency:
group: db-migrate
cancel-in-progress: false

steps:
- name: Checkout Repository
uses: actions/checkout@v7

- name: Set up Supabase CLI
uses: supabase/setup-cli@v1
with:
version: latest

- name: Show Migration Status
run: supabase migration list --db-url "$SUPABASE_DB_URL"
env:
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}

- name: Apply Migrations
run: supabase db push --db-url "$SUPABASE_DB_URL"
env:
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ExecutionContext } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AdminApiKeyGuard } from 'src/api/admin/common/guards/admin-api-key.guard';

function createContext(headers: Record<string, string>): ExecutionContext {
return {
switchToHttp: () => ({ getRequest: () => ({ headers }) }),
} as unknown as ExecutionContext;
}

function createGuard(apiKey: string | undefined): AdminApiKeyGuard {
return new AdminApiKeyGuard({ get: () => apiKey } as unknown as ConfigService);
}

describe('AdminApiKeyGuard', () => {
it('헤더의 키가 일치하면 통과시킨다', () => {
const guard = createGuard('secret');

expect(guard.canActivate(createContext({ 'x-admin-api-key': 'secret' }))).toBe(true);
});

it('헤더의 키가 다르거나 없으면 막는다', () => {
const guard = createGuard('secret');

expect(guard.canActivate(createContext({ 'x-admin-api-key': 'wrong' }))).toBe(false);
expect(guard.canActivate(createContext({}))).toBe(false);
});

it('ADMIN_API_KEY가 설정되지 않았으면 항상 막는다', () => {
const guard = createGuard(undefined);

expect(guard.canActivate(createContext({ 'x-admin-api-key': '' }))).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHash, timingSafeEqual } from 'crypto';

export const ADMIN_API_KEY_HEADER = 'x-admin-api-key';

@Injectable()
export class AdminApiKeyGuard implements CanActivate {
constructor(private readonly configService: ConfigService) {}

canActivate(context: ExecutionContext): boolean {
const expected = this.configService.get<string>('ADMIN_API_KEY');
// 키가 설정되지 않은 환경에서는 admin API를 막는다
if (!expected) return false;

const request = context.switchToHttp().getRequest();
const provided = request.headers[ADMIN_API_KEY_HEADER];
if (typeof provided !== 'string') return false;

return timingSafeEqual(digest(provided), digest(expected));
}
}

function digest(value: string): Buffer {
return createHash('sha256').update(value).digest();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {
ScrapeRun,
ScrapeRunType,
} from 'src/api/admin/scrape-runs/domain/entities/scrape-run.entity';

export interface ScraperStatusResult {
type: ScrapeRunType;
latestRun: ScrapeRun | null;
lastSucceededRun: ScrapeRun | null;
}

export interface ScrapeRunListResult {
runs: ScrapeRun[];
nextCursor: number | null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { ScrapeRunsService } from 'src/api/admin/scrape-runs/application/scrape-runs.service';
import { ScrapeRun } from 'src/api/admin/scrape-runs/domain/entities/scrape-run.entity';
import { ScrapeRunRepository } from 'src/api/admin/scrape-runs/infrastructure/scrape-run.repository';

function createRun(overrides: Partial<ScrapeRun>): ScrapeRun {
return {
id: 1,
type: 'shuttle',
trigger: 'cron',
status: 'succeeded',
errorMessage: null,
createdAt: new Date('2026-09-25T00:00:00.000Z'),
startedAt: null,
finishedAt: null,
...overrides,
};
}

describe('ScrapeRunsService', () => {
let repository: jest.Mocked<ScrapeRunRepository>;
let service: ScrapeRunsService;

beforeEach(() => {
repository = {
findMany: jest.fn(),
findById: jest.fn(),
findLatestPerType: jest.fn(),
findLastSucceededPerType: jest.fn(),
createPending: jest.fn(),
} as unknown as jest.Mocked<ScrapeRunRepository>;
service = new ScrapeRunsService(repository);
});

describe('getRuns', () => {
it('limit보다 많이 조회되면 마지막 항목 id를 다음 커서로 반환한다', async () => {
repository.findMany.mockResolvedValue([
createRun({ id: 5 }),
createRun({ id: 4 }),
createRun({ id: 3 }),
]);

const result = await service.getRuns({ type: 'shuttle', limit: 2 });

expect(repository.findMany).toHaveBeenCalledWith({ type: 'shuttle', limit: 3 });
expect(result.runs.map(run => run.id)).toEqual([5, 4]);
expect(result.nextCursor).toBe(4);
});

it('마지막 페이지면 다음 커서가 null이다', async () => {
repository.findMany.mockResolvedValue([createRun({ id: 1 })]);

const result = await service.getRuns({ limit: 2 });

expect(result.nextCursor).toBeNull();
});
});

describe('getRun', () => {
it('run이 없으면 NotFoundException을 던진다', async () => {
repository.findById.mockResolvedValue(null);

await expect(service.getRun(1)).rejects.toThrow(NotFoundException);
});
});

describe('requestRun', () => {
it('대기 상태의 수동 run을 등록한다', async () => {
const run = createRun({ id: 7, trigger: 'manual', status: 'pending' });
repository.createPending.mockResolvedValue(run);

await expect(service.requestRun('shuttle')).resolves.toBe(run);
expect(repository.createPending).toHaveBeenCalledWith('shuttle');
});

it('같은 타입이 이미 대기/실행 중이면 ConflictException을 던진다', async () => {
repository.createPending.mockResolvedValue(null);

await expect(service.requestRun('shuttle')).rejects.toThrow(ConflictException);
});
});

describe('getScraperStatuses', () => {
it('모든 수집 타입에 대해 최근 run과 최근 성공 run을 반환한다', async () => {
const failedShuttle = createRun({ id: 3, status: 'failed' });
const succeededShuttle = createRun({ id: 2 });
repository.findLatestPerType.mockResolvedValue([failedShuttle]);
repository.findLastSucceededPerType.mockResolvedValue([succeededShuttle]);

const result = await service.getScraperStatuses();

expect(result).toEqual([
{ type: 'shuttle', latestRun: failedShuttle, lastSucceededRun: succeededShuttle },
{ type: 'notice', latestRun: null, lastSucceededRun: null },
{ type: 'cafeteria', latestRun: null, lastSucceededRun: null },
{ type: 'academic-calendar', latestRun: null, lastSucceededRun: null },
]);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import {
ScrapeRunListResult,
ScraperStatusResult,
} from 'src/api/admin/scrape-runs/application/dtos/results/scraper-status-result.dto';
import {
SCRAPE_RUN_TYPES,
ScrapeRun,
ScrapeRunType,
} from 'src/api/admin/scrape-runs/domain/entities/scrape-run.entity';
import {
ScrapeRunRepository,
ScrapeRunSearchCondition,
} from 'src/api/admin/scrape-runs/infrastructure/scrape-run.repository';

@Injectable()
export class ScrapeRunsService {
constructor(private readonly scrapeRunRepository: ScrapeRunRepository) {}

public async getRuns(condition: ScrapeRunSearchCondition): Promise<ScrapeRunListResult> {
// 다음 페이지 존재 여부 확인을 위해 하나 더 조회한다
const runs = await this.scrapeRunRepository.findMany({
...condition,
limit: condition.limit + 1,
});
const hasNext = runs.length > condition.limit;
const page = hasNext ? runs.slice(0, condition.limit) : runs;

return {
runs: page,
nextCursor: hasNext ? Number(page[page.length - 1].id) : null,
};
}

public async getRun(id: number): Promise<ScrapeRun> {
const run = await this.scrapeRunRepository.findById(id);
if (!run) {
throw new NotFoundException(`${id}번 수집 실행 기록을 찾을 수 없습니다.`);
}
return run;
}

public async requestRun(type: ScrapeRunType): Promise<ScrapeRun> {
const run = await this.scrapeRunRepository.createPending(type);
if (!run) {
throw new ConflictException(`'${type}' 수집이 이미 대기 또는 실행 중입니다.`);
}
return run;
}

public async getScraperStatuses(): Promise<ScraperStatusResult[]> {
const [latestRuns, succeededRuns] = await Promise.all([
this.scrapeRunRepository.findLatestPerType(),
this.scrapeRunRepository.findLastSucceededPerType(),
]);

return SCRAPE_RUN_TYPES.map(type => ({
type,
latestRun: latestRuns.find(run => run.type === type) ?? null,
lastSucceededRun: succeededRuns.find(run => run.type === type) ?? null,
}));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

export const SCRAPE_RUN_TYPES = ['shuttle', 'notice', 'cafeteria', 'academic-calendar'] as const;
export type ScrapeRunType = (typeof SCRAPE_RUN_TYPES)[number];

export const SCRAPE_RUN_STATUSES = ['pending', 'running', 'succeeded', 'failed'] as const;
export type ScrapeRunStatus = (typeof SCRAPE_RUN_STATUSES)[number];

export type ScrapeRunTrigger = 'cron' | 'manual';

@Entity('scrape_run')
export class ScrapeRun {
@PrimaryGeneratedColumn()
id: number;

@Column({ length: 30 })
type: ScrapeRunType;

@Column({ length: 10 })
trigger: ScrapeRunTrigger;

@Column({ length: 10 })
status: ScrapeRunStatus;

@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage: string | null;

@Column({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;

@Column({ name: 'started_at', type: 'timestamptz', nullable: true })
startedAt: Date | null;

@Column({ name: 'finished_at', type: 'timestamptz', nullable: true })
finishedAt: Date | null;
}
Loading
Loading