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
148 changes: 56 additions & 92 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,132 +1,96 @@
<div align="center">

# Dreamhack Readme Stats

GitHub README 프로필에 표시할 수 있는 Dreamhack 워게임 통계 SVG 생성기입니다.
**GitHub README에 Dreamhack 워게임 통계를 표시하세요**

## 사용 방법
[![Used By](https://img.shields.io/badge/used%20by-13%20README.md-blue)](https://github.com/search?q=%22dreamhack-readme-stats.vercel.app%2Fapi%2F%22+in%3Afile+filename%3AREADME.md&type=code)
![GitHub release](https://img.shields.io/github/v/release/with-developer/dreamhack-readme-stats)
![License](https://img.shields.io/github/license/with-developer/dreamhack-readme-stats)

### Markdown
<br />

```markdown
![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=사용자명)
```
![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness)

### HTML
![Dreamhack Category Chart](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=weakness)

```html
<a href="https://dreamhack.io/users/사용자명" target="_blank" rel="noopener noreferrer">
<img src="https://dreamhack-readme-stats.vercel.app/api/stats?username=사용자명" alt="Dreamhack Stats" />
</a>
```
</div>

실제 사용 시에는 `사용자명`을 여러분의 Dreamhack 사용자 이름으로 변경하세요.
---

## 예시
## Quick Start

다음은 실제 렌더링된 결과입니다:
README에 아래 코드를 추가하고 `사용자명`을 본인의 Dreamhack 닉네임으로 변경하세요.

![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness)
### Wargame Stats

마크다운 코드:
```markdown
![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness)
```

HTML 코드:
```html
<a href="https://dreamhack.io/users/weakness" target="_blank" rel="noopener noreferrer">
<img src="https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness" alt="Dreamhack Stats" />
</a>
![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=사용자명)
```

## 카테고리 차트

Dreamhack의 워게임 카테고리별 점수를 원형 차트로 표시합니다. 각 카테고리별 점수와 랭킹을 확인할 수 있습니다.
### Most Solved Categories

### 사용 방법

#### Markdown
```markdown
![Dreamhack Category Chart](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=사용자명)
![Dreamhack Categories](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=사용자명)
```

#### HTML
```html
<a href="https://dreamhack.io/users/사용자명" target="_blank" rel="noopener noreferrer">
<img src="https://dreamhack-readme-stats.vercel.app/api/most-solved?username=사용자명" alt="Dreamhack Category Chart" />
</a>
```
> 💡 클릭 시 Dreamhack 프로필로 이동하게 하려면 HTML 사용:
> ```html
> <a href="https://dreamhack.io/users/사용자명">
> <img src="https://dreamhack-readme-stats.vercel.app/api/stats?username=사용자명" />
> </a>
> ```

### 예시
---

![Dreamhack Category Chart](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=weakness)
## Features

## 기술 스택
| Feature | Description |
|---------|-------------|
| **Wargame Stats** | 해결한 문제 수, 랭킹, 점수, TOP % 표시 |
| **Category Chart** | 카테고리별 점수 분포를 파이 차트로 시각화 |
| **Auto Update** | 실시간으로 최신 통계 반영 |
| **Caching** | Redis 캐싱으로 빠른 응답 속도 |

- Next.js
- TypeScript
- Node.js
- Redis (캐싱)
---

## 로컬에서 실행하기
## Local Development

1. 저장소 클론
```
git clone https://github.com/yourusername/dreamhack-readme-stats.git
```bash
# 저장소 클론
git clone https://github.com/with-developer/dreamhack-readme-stats.git
cd dreamhack-readme-stats
```

2. 의존성 설치
```
# 의존성 설치
npm install
```

3. 개발 서버 실행
```
npm run dev
```

4. 브라우저에서 확인
```
http://localhost:3000
```

## Redis 캐싱 설정

성능 향상을 위해 Redis 캐싱을 사용합니다. 사용자 ID 조회 결과를 캐싱하여 API 응답 시간을 크게 단축합니다.

### 로컬 환경에서 Redis 설정하기

1. `.env.local.example` 파일을 `.env.local`로 복사합니다.
```
# 환경변수 설정
cp .env.local.example .env.local

# 개발 서버 실행
npm run dev
```

2. `.env.local` 파일을 편집하여 Redis 연결 정보를 설정합니다.
http://localhost:3000 에서 확인

Redis 연결은 두 가지 방법으로 설정할 수 있습니다:
### Environment Variables

#### 방법 1: REDIS_URL 사용 (권장)
```
REDIS_URL=redis://username:password@host:port
```
| Variable | Required | Description |
|----------|----------|-------------|
| `REDIS_URL` | No | Redis 연결 URL (캐싱용) |
| `GITHUB_TOKEN` | No | GitHub API 토큰 (사용자 수 집계용) |

#### 방법 2: 개별 설정 사용
```
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_USERNAME=default
REDIS_PASSWORD=your_password
REDIS_TLS=false
```
---

### Redis 서비스 제공업체
## Tech Stack

다음과 같은 Redis 서비스를 사용할 수 있습니다:
- **Framework**: Next.js 14
- **Language**: TypeScript
- **Cache**: Redis (Upstash)
- **Deploy**: Vercel

- [Upstash](https://upstash.com/) - 서버리스 Redis (무료 티어 제공)
- [Redis Cloud](https://redis.com/redis-enterprise-cloud/overview/) - 관리형 Redis 서비스
- 로컬 Redis 서버
---

### Redis 없이 실행하기
## License

Redis 설정이 없어도 애플리케이션은 정상적으로 작동합니다. 다만, 캐싱 기능이 비활성화되어 모든 요청이 Dreamhack API를 직접 호출하게 됩니다.
MIT License
92 changes: 92 additions & 0 deletions src/__tests__/pages/api/users-count.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { createMocks } from 'node-mocks-http';
import handler from '../../../pages/api/users-count';

// fetch 모킹
global.fetch = jest.fn();

describe('users-count API 엔드포인트 테스트', () => {
const originalEnv = process.env;

beforeEach(() => {
jest.clearAllMocks();
process.env = { ...originalEnv, GITHUB_TOKEN: 'test-token' };
});

afterEach(() => {
process.env = originalEnv;
});

it('고유 사용자 수를 shields.io 형식으로 반환해야 함', async () => {
const mockResponse = {
total_count: 3,
items: [
{ repository: { owner: { login: 'user1' } } },
{ repository: { owner: { login: 'user2' } } },
{ repository: { owner: { login: 'user1' } } }, // 중복 사용자
]
};

(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockResponse
});

const { req, res } = createMocks({
method: 'GET',
});

await handler(req, res);

expect(res._getStatusCode()).toBe(200);
const data = JSON.parse(res._getData());
expect(data).toEqual({
schemaVersion: 1,
label: 'users',
message: '2', // 고유 사용자 2명
color: 'blue'
});
});

it('GITHUB_TOKEN이 없으면 500 에러를 반환해야 함', async () => {
delete process.env.GITHUB_TOKEN;

const { req, res } = createMocks({
method: 'GET',
});

await handler(req, res);

expect(res._getStatusCode()).toBe(500);
expect(JSON.parse(res._getData())).toEqual({ error: 'GitHub token not configured' });
});

it('GitHub API 에러 시 해당 상태 코드를 반환해야 함', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 403,
text: async () => 'Rate limit exceeded'
});

const { req, res } = createMocks({
method: 'GET',
});

await handler(req, res);

expect(res._getStatusCode()).toBe(403);
expect(JSON.parse(res._getData())).toEqual({ error: 'GitHub API error' });
});

it('fetch 예외 발생 시 500 에러를 반환해야 함', async () => {
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));

const { req, res } = createMocks({
method: 'GET',
});

await handler(req, res);

expect(res._getStatusCode()).toBe(500);
expect(JSON.parse(res._getData())).toEqual({ error: 'Internal server error' });
});
});
62 changes: 62 additions & 0 deletions src/pages/api/users-count.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { NextApiRequest, NextApiResponse } from 'next';

interface SearchItem {
repository: {
owner: {
login: string;
};
};
}

interface SearchResponse {
total_count: number;
items: SearchItem[];
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const githubToken = process.env.GITHUB_TOKEN;

if (!githubToken) {
return res.status(500).json({ error: 'GitHub token not configured' });
}

try {
const query = encodeURIComponent('"dreamhack-readme-stats.vercel.app/api/" in:file filename:README.md');
const response = await fetch(
`https://api.github.com/search/code?q=${query}&per_page=100`,
{
headers: {
'Authorization': `Bearer ${githubToken}`,
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
}
}
);

if (!response.ok) {
const error = await response.text();
console.error('GitHub API error:', error);
return res.status(response.status).json({ error: 'GitHub API error' });
}

const data: SearchResponse = await response.json();

// 고유 사용자 수 계산
const uniqueUsers = new Set(
data.items.map(item => item.repository.owner.login)
);
const count = uniqueUsers.size;

// shields.io endpoint format
res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=3600');
return res.status(200).json({
schemaVersion: 1,
label: 'users',
message: String(count),
color: 'blue'
});
} catch (error) {
console.error('Error fetching user count:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}