Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e2d2134
✨ feat: 어드민 실행 기록 대상에 구조화된 메타데이터(targetMeta) 추가
JangDongHo Sep 29, 2026
77b8233
✨ feat: 어드민 실행 기록 대상 셀을 두 줄로 표시하고 상세 패널에 메타데이터 노출
JangDongHo Sep 29, 2026
28c8c80
🔧 chore: .superpowers/ 디렉토리를 gitignore에 추가
JangDongHo Sep 29, 2026
85aeb7c
Merge pull request #106 from GNU-connect/feat/admin-scrape-target-meta
JangDongHo Sep 29, 2026
9c34d42
🐛 fix: batch에서 bufferLogs로 쌓인 로그가 flush되지 않는 문제 수정
JangDongHo Sep 30, 2026
383f012
Merge pull request #107 from GNU-connect/fix/batch-winston-buffer-flush
JangDongHo Sep 30, 2026
72d5398
🔧 chore: api/batch 도커 이미지를 amd64/arm64 멀티아치로 빌드
JangDongHo Oct 5, 2026
14214fe
🔧 chore: api/batch 도커 이미지를 arm64 단일 플랫폼으로 빌드
JangDongHo Oct 5, 2026
75a3ac7
Merge pull request #110 from GNU-connect/chore/multi-arch-docker-image
JangDongHo Oct 5, 2026
42affb9
📝 docs: 어드민 ID/PW 로그인 설계와 구현 계획 추가
JangDongHo Oct 6, 2026
6257f75
✨ feat: 어드민 계정·세션 테이블과 엔티티 추가
JangDongHo Oct 6, 2026
4673c82
✨ feat: 어드민 로그인 서비스와 레포지토리 추가
JangDongHo Oct 6, 2026
f51c674
✨ feat: 어드민 세션 쿠키 헬퍼와 AdminSessionGuard 추가
JangDongHo Oct 6, 2026
e2e318e
✨ feat: 어드민 로그인 API 추가하고 API 키 가드를 세션 가드로 교체
JangDongHo Oct 6, 2026
6e9783e
✨ feat: 어드민 계정 시드 스크립트 추가
JangDongHo Oct 6, 2026
e2604ee
✨ feat: 어드민 웹 로그인을 이메일/비밀번호와 쿠키 세션으로 변경
JangDongHo Oct 6, 2026
f9d27bc
📝 docs: 어드민 로그인 방식 변경 안내 반영
JangDongHo Oct 6, 2026
7f42a8e
🐛 fix: 시드 스크립트가 운영 Docker 빌드에 포함되도록 빌드 설정 복구
JangDongHo Oct 6, 2026
9ea882c
🐛 fix: 동시 로그인 요청에서 계정 잠금이 우회되지 않도록 조건부 UPDATE로 변경
JangDongHo Oct 6, 2026
2815e42
🐛 fix: 비밀번호와 세션 쿠키가 Sentry로 전송되지 않도록 차단
JangDongHo Oct 6, 2026
9df0138
🐛 fix: 시드 입력의 비밀번호 길이와 이메일 형식을 로그인 DTO에 맞춰 검증
JangDongHo Oct 6, 2026
4781713
📝 docs: 어드민 시드 실행 방법과 운영 프록시 주소 정정
JangDongHo Oct 6, 2026
c2cde93
Merge pull request #111 from GNU-connect/feat/admin-id-pw-login
JangDongHo Oct 6, 2026
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
4 changes: 4 additions & 0 deletions .github/workflows/api-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v7

- name: Set up QEMU
uses: docker/setup-qemu-action@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

Expand All @@ -56,6 +59,7 @@ jobs:
with:
context: services/api/app
target: prod
platforms: linux/arm64
push: true
pull: true
tags: |
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/batch-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v7

- name: Set up QEMU
uses: docker/setup-qemu-action@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

Expand All @@ -56,6 +59,7 @@ jobs:
with:
context: services/api/batch
target: prod
platforms: linux/arm64
push: true
pull: true
tags: |
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,6 @@ lerna-debug.log*
.cursor/settings.json

# pnpm store
.pnpm-store/
.pnpm-store/

.superpowers/
2 changes: 1 addition & 1 deletion admin/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# /api 요청을 넘길 API 서버 주소. 운영 서버: https://api.connectgnu.kro.kr
# /api 요청을 넘길 API 서버 주소. 운영 서버: https://admin.connectgnu.kro.kr
VITE_API_TARGET=http://localhost:3000
17 changes: 14 additions & 3 deletions admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,23 @@
```bash
cd admin
pnpm install
cp .env.example .env.local # 운영 서버에 붙이려면 VITE_API_TARGET=https://api.connectgnu.kro.kr
cp .env.example .env.local # 운영 서버에 붙이려면 VITE_API_TARGET=https://admin.connectgnu.kro.kr
pnpm dev # http://localhost:5173
```

로그인 화면에 API 서버의 `ADMIN_API_KEY` 값을 넣어요. 키는 브라우저 탭의 sessionStorage에만 남고, 탭을 닫으면 사라져요.
API 서버에 `ADMIN_API_KEY`가 설정돼 있지 않으면 모든 admin 요청이 403이라 로그인할 수 없어요.
로그인 화면에 운영자 계정의 이메일과 비밀번호를 넣어요. 서버가 DB에 세션을 만들고 HttpOnly 쿠키로 돌려줘요(24시간, 쓰는 동안 연장). 연속 5번 틀리면 15분 동안 잠겨요.
계정은 API 서버에서 시드 스크립트로 만들어요. 비밀번호를 명령줄에 적지 않도록 먼저 입력받아 환경변수로 올려요.

```bash
# 운영 (서버에서)
read -s ADMIN_SEED_PASSWORD; export ADMIN_SEED_PASSWORD
docker compose exec -e ADMIN_SEED_EMAIL=admin@example.com -e ADMIN_SEED_PASSWORD app node dist/scripts/seed-admin.js

# 로컬 (services/api/app에서 pnpm build 뒤에)
# 스크립트는 .env를 읽지 않으므로 DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_DATABASE도 설정돼 있어야 해요.
read -s ADMIN_SEED_PASSWORD; export ADMIN_SEED_PASSWORD
ADMIN_SEED_EMAIL=admin@example.com node --env-file=.env dist/scripts/seed-admin.js
```

## 화면

Expand Down
68 changes: 54 additions & 14 deletions admin/src/api/adminClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { jsonResponse, mockFetch, ok } from '../test/http';
import { makeRun } from '../test/fixtures';
import {
getMe,
getScrapeRun,
getScraperStatuses,
listScrapeRuns,
login,
logout,
requestScrapeRun,
} from './adminClient';
import {
Expand All @@ -15,28 +18,29 @@ import {
} from './errors';

describe('adminClient', () => {
it('키 헤더를 붙여 /api/admin/scrapers를 부르고 data를 꺼낸다', async () => {
it('쿠키를 함께 보내 /api/admin/scrapers를 부르고 data를 꺼낸다', async () => {
const fetchMock = mockFetch().mockResolvedValue(ok([]));

await expect(getScraperStatuses('secret')).resolves.toEqual([]);
await expect(getScraperStatuses()).resolves.toEqual([]);

const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/admin/scrapers');
expect(init.headers['x-admin-api-key']).toBe('secret');
expect(init.credentials).toBe('same-origin');
expect(init.headers['x-admin-api-key']).toBeUndefined();
});

it('목록 조회는 값이 있는 파라미터만 쿼리로 보낸다', async () => {
const fetchMock = mockFetch().mockResolvedValue(ok({ items: [], nextCursor: null }));

await listScrapeRuns('k', { type: 'cafeteria', status: undefined, cursor: 40, limit: 20 });
await listScrapeRuns({ type: 'cafeteria', status: undefined, cursor: 40, limit: 20 });

expect(fetchMock.mock.calls[0][0]).toBe('/api/admin/scrape-runs?type=cafeteria&cursor=40&limit=20');
});

it('파라미터가 없으면 쿼리 문자열을 붙이지 않는다', async () => {
const fetchMock = mockFetch().mockResolvedValue(ok({ items: [], nextCursor: null }));

await listScrapeRuns('k');
await listScrapeRuns();

expect(fetchMock.mock.calls[0][0]).toBe('/api/admin/scrape-runs');
});
Expand All @@ -45,15 +49,15 @@ describe('adminClient', () => {
const run = makeRun({ id: 7 });
const fetchMock = mockFetch().mockResolvedValue(ok(run));

await expect(getScrapeRun('k', 7)).resolves.toEqual(run);
await expect(getScrapeRun(7)).resolves.toEqual(run);
expect(fetchMock.mock.calls[0][0]).toBe('/api/admin/scrape-runs/7');
});

it('수동 실행은 JSON 본문으로 POST 하고 등록된 run 목록을 돌려준다', async () => {
const run = makeRun({ status: 'pending', trigger: 'manual' });
const fetchMock = mockFetch().mockResolvedValue(ok({ runs: [run] }, 202));

await expect(requestScrapeRun('k', 'shuttle')).resolves.toEqual([run]);
await expect(requestScrapeRun('shuttle')).resolves.toEqual([run]);

const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/admin/scrape-runs');
Expand All @@ -65,23 +69,23 @@ describe('adminClient', () => {
it('대상을 지정한 수동 실행은 target도 본문에 담는다', async () => {
const fetchMock = mockFetch().mockResolvedValue(ok({ runs: [] }, 202));

await requestScrapeRun('k', 'cafeteria', '3');
await requestScrapeRun('cafeteria', '3');

expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ type: 'cafeteria', target: '3' });
});

it('목록 조회는 target도 쿼리로 보낸다', async () => {
const fetchMock = mockFetch().mockResolvedValue(ok({ items: [], nextCursor: null }));

await listScrapeRuns('k', { type: 'cafeteria', target: '3' });
await listScrapeRuns({ type: 'cafeteria', target: '3' });

expect(fetchMock.mock.calls[0][0]).toBe('/api/admin/scrape-runs?type=cafeteria&target=3');
});

it.each([401, 403])('%i 응답은 UnauthorizedError', async status => {
mockFetch().mockResolvedValue(jsonResponse(status, { statusCode: status, message: 'Forbidden resource' }));

const error = await getScraperStatuses('bad').catch(e => e);
const error = await getScraperStatuses().catch(e => e);

expect(error).toBeInstanceOf(UnauthorizedError);
expect(error.status).toBe(status);
Expand All @@ -92,7 +96,7 @@ describe('adminClient', () => {
jsonResponse(409, { statusCode: 409, message: "'shuttle' 수집이 이미 대기 또는 실행 중입니다." }),
);

const error = await requestScrapeRun('k', 'shuttle').catch(e => e);
const error = await requestScrapeRun('shuttle').catch(e => e);

expect(error).toBeInstanceOf(ConflictError);
expect(error.message).toBe("'shuttle' 수집이 이미 대기 또는 실행 중입니다.");
Expand All @@ -103,7 +107,7 @@ describe('adminClient', () => {
jsonResponse(400, { statusCode: 400, message: ['type must be one of', 'limit must not be greater than 100'] }),
);

const error = await listScrapeRuns('k').catch(e => e);
const error = await listScrapeRuns().catch(e => e);

expect(error).toBeInstanceOf(ApiError);
expect(error.message).toBe('type must be one of, limit must not be greater than 100');
Expand All @@ -112,7 +116,7 @@ describe('adminClient', () => {
it('JSON이 아닌 5xx(프록시 오류 등)는 HTTP 상태를 담은 ApiError', async () => {
mockFetch().mockResolvedValue(new Response('Bad Gateway', { status: 502 }));

const error = await getScraperStatuses('k').catch(e => e);
const error = await getScraperStatuses().catch(e => e);

expect(error).toBeInstanceOf(ApiError);
expect(error).not.toBeInstanceOf(UnauthorizedError);
Expand All @@ -123,7 +127,7 @@ describe('adminClient', () => {
it('fetch 자체가 실패하면 NetworkError', async () => {
mockFetch().mockRejectedValue(new TypeError('Failed to fetch'));

await expect(getScraperStatuses('k')).rejects.toBeInstanceOf(NetworkError);
await expect(getScraperStatuses()).rejects.toBeInstanceOf(NetworkError);
});
});

Expand All @@ -139,4 +143,40 @@ describe('errorText', () => {
'알 수 없는 문제가 생겼어요. 잠시 뒤 다시 시도해 주세요.',
);
});

it('로그인은 이메일과 비밀번호를 JSON으로 POST 하고 사용자를 돌려준다', async () => {
const fetchMock = mockFetch().mockResolvedValue(ok({ email: 'admin@example.com' }));

await expect(login('admin@example.com', 'pw-123456')).resolves.toEqual({ email: 'admin@example.com' });

const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/admin/auth/login');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ email: 'admin@example.com', password: 'pw-123456' });
});

it('로그인 상태 확인과 로그아웃 경로', async () => {
const fetchMock = mockFetch()
.mockResolvedValueOnce(ok({ email: 'admin@example.com' }))
.mockResolvedValueOnce(ok(null));

await getMe();
await logout();

expect(fetchMock.mock.calls[0][0]).toBe('/api/admin/auth/me');
expect(fetchMock.mock.calls[1][0]).toBe('/api/admin/auth/logout');
expect(fetchMock.mock.calls[1][1].method).toBe('POST');
});

it('잠긴 계정(429)은 서버 문구를 담은 ApiError로 던진다', async () => {
mockFetch().mockResolvedValue(
jsonResponse(429, { statusCode: 429, message: '로그인 시도가 너무 많아요. 15분 뒤에 다시 시도해 주세요.' }),
);

const error = await login('admin@example.com', 'pw-123456').catch(e => e);

expect(error).toBeInstanceOf(ApiError);
expect(error).not.toBeInstanceOf(UnauthorizedError);
expect(error.message).toBe('로그인 시도가 너무 많아요. 15분 뒤에 다시 시도해 주세요.');
});
});
47 changes: 25 additions & 22 deletions admin/src/api/adminClient.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ApiError, ConflictError, NetworkError, UnauthorizedError } from './errors';
import type {
AdminUser,
ListScrapeRunsParams,
ScrapeRun,
ScrapeRunPage,
Expand All @@ -8,51 +9,53 @@ import type {
} from './types';

const BASE_PATH = '/api/admin';
const ADMIN_API_KEY_HEADER = 'x-admin-api-key';

export function getScraperStatuses(apiKey: string): Promise<ScraperStatus[]> {
return request(apiKey, '/scrapers');
export function getMe(): Promise<AdminUser> {
return request('/auth/me');
}

export function listScrapeRuns(
apiKey: string,
params: ListScrapeRunsParams = {},
): Promise<ScrapeRunPage> {
export function login(email: string, password: string): Promise<AdminUser> {
return request('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) });
}

export function logout(): Promise<null> {
return request('/auth/logout', { method: 'POST' });
}

export function getScraperStatuses(): Promise<ScraperStatus[]> {
return request('/scrapers');
}

export function listScrapeRuns(params: ListScrapeRunsParams = {}): Promise<ScrapeRunPage> {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) query.set(key, String(value));
}
const qs = query.toString();
return request(apiKey, `/scrape-runs${qs ? `?${qs}` : ''}`);
return request(`/scrape-runs${qs ? `?${qs}` : ''}`);
}

export function getScrapeRun(apiKey: string, id: number): Promise<ScrapeRun> {
return request(apiKey, `/scrape-runs/${id}`);
export function getScrapeRun(id: number): Promise<ScrapeRun> {
return request(`/scrape-runs/${id}`);
}

export async function requestScrapeRun(
apiKey: string,
type: ScrapeRunType,
target?: string,
): Promise<ScrapeRun[]> {
export async function requestScrapeRun(type: ScrapeRunType, target?: string): Promise<ScrapeRun[]> {
const body = target === undefined ? { type } : { type, target };
const data = await request<{ runs: ScrapeRun[] }>(apiKey, '/scrape-runs', {
const data = await request<{ runs: ScrapeRun[] }>('/scrape-runs', {
method: 'POST',
body: JSON.stringify(body),
});
return data.runs;
}

async function request<T>(apiKey: string, path: string, init: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
Accept: 'application/json',
[ADMIN_API_KEY_HEADER]: apiKey,
};
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = { Accept: 'application/json' };
if (init.body) headers['Content-Type'] = 'application/json';

let response: Response;
try {
response = await fetch(`${BASE_PATH}${path}`, { ...init, headers });
// 세션 쿠키는 브라우저가 같은 출처 요청에 알아서 붙인다
response = await fetch(`${BASE_PATH}${path}`, { ...init, headers, credentials: 'same-origin' });
} catch {
throw new NetworkError();
}
Expand Down
2 changes: 1 addition & 1 deletion admin/src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export class ApiError extends Error {
}
}

/** 401/403. 키가 없거나 틀림 */
/** 401/403. 로그인하지 않았거나 세션이 만료됨 */
export class UnauthorizedError extends ApiError {
constructor(status: number, message: string) {
super(status, message);
Expand Down
14 changes: 13 additions & 1 deletion admin/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,21 @@ export type ScrapeRunStatus = (typeof SCRAPE_RUN_STATUSES)[number];

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

/** 수집 대상을 설명하는 항목 하나(예: 캠퍼스 → 가좌캠퍼스) */
export interface ScrapeTargetMeta {
label: string;
value: string;
}

export interface ScrapeRun {
id: number;
type: ScrapeRunType;
/** 수집 대상 id(식당, 공지 카테고리). 대상 없는 타입은 null */
target: string | null;
/** 수집 대상 이름 */
/** 수집 대상 대표 이름(targetMeta의 마지막 값) */
targetName: string | null;
/** 수집 대상 메타데이터. 일반 → 구체 순서. 대상이 없거나 찾지 못하면 빈 배열 */
targetMeta: ScrapeTargetMeta[];
trigger: ScrapeRunTrigger;
status: ScrapeRunStatus;
errorMessage: string | null;
Expand Down Expand Up @@ -53,3 +61,7 @@ export interface ListScrapeRunsParams {
cursor?: number;
limit?: number;
}

export interface AdminUser {
email: string;
}
Loading
Loading