Compare commits

..
36 Commits
Author SHA1 Message Date
hyoseung930 c5ca403442 autoCoupon: alliance 연맹원 목록 연동 추가
Build and Deploy / deploy-back (push) Successful in 21s
Build and Deploy / deploy-front (push) Successful in 10s
- /api/alliances: alliance 백엔드 프록시
- /api/alliance-members: R5 PIN 인증 후 연맹원 목록 반환 (쿠폰 등록 여부 포함)
- AlliancePanel.vue: 연맹 선택 + R5 PIN 인증 + 역할별 필터 + 쿠폰 등록 현황
- HomeView.vue에 AlliancePanel 추가
2026-06-26 15:05:36 +09:00
hyoseung930 872aabf0e9 New task
배포됩니다. 쿠폰 간 딜레이 **30초**로 변경됐습니다.
2026-05-19 18:34:09 +09:00
hyoseung930 f87444bac2 New task
배포됩니다. 쿠폰 간 딜레이 5초 → **10초**로 변경했습니다.
2026-05-19 18:28:20 +09:00
hyoseung930 68bb9e2863 New task
배포됩니다.

**로직:**
- 40103(OCR 오인식) → **30초 대기 → 재시도 → 무한 반복** (성공할 때까지)
- 성공 → 다음 쿠폰으로
- 40103 외 다른 에러(만료, 이미수령 등) → 즉시 종료
2026-05-19 18:20:03 +09:00
hyoseung930 8c70714ed6 New task
배포됩니다.

**로직:**
- 1차 시도: 캡차 요청 → 지급
- 성공 → 즉시 다음 쿠폰으로
- 40103(OCR 오인식) → **30초 대기** → 2차 재시도 (이때 WOS 레이트 리밋 완전 리셋)
- 2차도 실패 → 그냥 실패 처리
2026-05-19 18:19:17 +09:00
hyoseung930 e19c08b725 New task
CI/CD 배포 시작됐습니다.

**핵심 변경:**
- 쿠폰 1개 = 캡차 요청 **정확히 1번** (재시도 완전 제거)
- OCR이 틀려도 바로 실패 처리 → TOO FREQUENT 원천 차단
- 쿠폰 간 5초 딜레이 유지

**트레이드오프:** OCR이 틀리면 해당 쿠폰은 실패로 표시됩니다. 그럴 경우 다시 "쿠폰 받기" 버튼 누르면 됩니다 (이미 성공한 쿠폰은 `alreadySucceeded` 로 스킵하고, 실패한 것만 재시도).
2026-05-19 18:14:22 +09:00
hyoseung930 a7ea6ef7ad New task
CI/CD 배포 시작됐습니다. 로그 분석 결과:

**발견된 3가지 문제:**

| 에러 | 원인 | 수정 |
|------|------|------|
| `USED. (40005)` | 이미 사용한 쿠폰인데 "실패"로 처리됨 | 40008/40011과 동일하게 **"이미 수령"** 처리 |
| `Sign Error (err=0)` | OCR이 `6g>c` 처럼 특수문자 오인식 → 서버가 Sign Error 반환 | **재시도**하도록 추가 |
| `CAPTCHA TOO FREQUENT` | 연속 캡차 요청 → 이전 수정에서 10초 대기+재시도 적용됨 |  이미 배포됨 |
2026-05-19 18:03:53 +09:00
hyoseung930 357d6dc911 New task
CI/CD로 배포 시작됐습니다.

**원인 및 수정 내용:**

**버그:** `solveCaptcha()`가 "TOO FREQUENT" 에러를 던지면 catch에서 **즉시 return**해서 재시도 루프를 탈출했음

**수정:**
- TOO FREQUENT 에러 감지 시 → **10초 대기 후 재시도** (최대 5회까지)
- 재시도 간 기본 딜레이: 2초 → **3초**
- 쿠폰 간 딜레이: 3초 → **5초** (연속 쿠폰 처리 시 WOS 서버 부하 감소)
2026-05-19 17:54:58 +09:00
hyoseung930 9d133ab21e New task
푸시 완료됐습니다. CI/CD가 자동으로 배포합니다.

**변경 내용:**
- 모바일에서 2단 컬럼 유지 (column 전환 제거)
- 왼쪽 유저 리스트: 너비 72px의 좁은 사이드바로 고정
- 닉네임 텍스트 숨김, 아바타 이미지만 표시 (36×36px)
- 검색창 숨김 (공간 절약)
- 아바타 탭으로 유저 선택 가능
- 오른쪽 메인 영역이 나머지 공간 전부 차지
2026-05-19 17:51:37 +09:00
hyoseung930 54db115b05 New task
## 수정 내용

**CAPTCHA TOO FREQUENT 해결:**
- `redeemOne`에서 **불필요한 PLAYER API 호출 완전 제거** (각 쿠폰마다 player 호출하던 것 삭제)
- 캡차 재시도 시 **2초 딜레이** 추가
- 쿠폰 간 딜레이 **1.5초 → 3초**로 증가

**모바일 반응형:**
- 640px 이하에서 2단 컬럼 → **세로 1단** 전환
- FID 입력창 + 버튼 세로 배치
- 카드 패딩/글자 크기 모바일에 맞게 조정
2026-05-19 17:49:32 +09:00
hyoseung930 cd6c296fb7 New task
## 작업 결과

**색상 통일 (화이트 테마):**
- `CouponInput.vue` — 결과 행 배경 `#0f172a` → `#f8fafc`, 텍스트/배지 라이트로
- `CouponManager.vue` — 입력창/쿠폰 리스트 배경/텍스트 전체 라이트로
- `HistoryList.vue` — 이력 아이템 배경/텍스트 라이트로, 스크롤바도 라이트

**CI/CD 설정:**
- Gitea `git.wageulwageul.com/hyoseung/coupon` 레포 생성
- `.gitea/workflows/deploy.yml` — front/back 각각 빌드 후 rsync 배포
- 백엔드 `ubuntu` PM2 → `hyoseung` PM2로 마이그레이션 (CI/CD 권한 문제 해결)
- 첫 푸시에 CI/CD 자동 실행 완료 

이제부터는 `autoCoupon/` 폴더에서 코드 수정 후 `git push origin main` 하면 자동 배포됩니다.
2026-05-19 17:47:08 +09:00
hyoseung930 765868079d fix: FID 입력창 자동 포커스 추가 - Ctrl+V 붙여넣기 동작 2026-05-19 17:34:36 +09:00
hyoseung930 b160ec3948 New task
모두 완료되었습니다!

---

## 구성 완료 요약

**Gitea**: https://git.wageulwageul.com
- 계정: `hyoseung` / `486251daKWON@`
- 비공개 저장소 5개 (budgetTrack, alliance, nyami, lotto, autoCoupon)

**자동 배포 흐름:**
```
git push gitea main
    → Gitea webhook → localhost:9000 (webhook-server)
    → /home/hyoseung/deploy.sh <project>
    → git pull → npm build → pm2 restart → nginx 업데이트
```

---

## 다른 컴퓨터에서 사용하는 법

```bash
# clone
git clone https://hyoseung:486251daKWON%40@git.wageulwageul.com/hyoseung/budgetTrack.git

# 코드 수정 후 push
git add -A
git commit -m "변경 내용"
git push gitea main
# → 자동으로 EC2에 배포됨!
```

현재 이 컴퓨터는 이미 각 프로젝트 폴더에 `gitea` remote가 설정되어 있어서 바로 `git push gitea main` 하면 됩니다.
2026-05-11 16:36:56 +09:00
hyoseung930 5eae3319e9 New task
**완벽하게 동작해요!**

```json
{"found": 4, "added": []}
```

- **found: 4** → Wiki에서 코드 4개 정상 파싱됨
- **added: []** → 이미 DB에 있는 코드들 (전부 중복)

Wiki 코드 4개 (`HappyMayDay`, `GW2026JP`, `OFFICIALSTORE`, `gogoWOS`)가 이미 DB에 등록되어 있어서 스킵된 거예요.

---

## 정리된 동작

```mermaid
flowchart LR
    A[매 정시 크론] --> B[whiteoutsurvival.wiki 페이지 다운로드]
    B --> C[span.code 파싱 - 정규식]
    C --> D{DB 중복 체크}
    D -->|기존 코드| E[스킵]
    D -->|신규 코드| F[addCoupon 호출]
    F --> G[전체 유저 자동 지급]
    F --> H[Discord Webhook 알림]
```

이제 **매 정시마다 자동으로 Wiki를 확인**하고, 새 쿠폰이 추가되면 자동으로 전체 연맹원에게 지급돼요. Reddit OAuth 같은 복잡한 설정도 필요 없어요!
2026-05-08 10:29:04 +09:00
hyoseung930 07dfaaf71b New task
서버 정상 동작 확인! 이제 Reddit 앱 발급이 필요해요.

---

## Reddit 앱 발급 (2분)

**1단계**: https://www.reddit.com/prefs/apps 접속 → **"are you a developer? create an app..."** 클릭

**2단계** 양식 입력:
- **name**: `WosCouponBot`
- **type**: `script` 선택
- **redirect uri**: `http://localhost` (아무거나 OK)

**3단계** 생성 후 표시되는 값 복사:
```
client_id:     앱 이름 아래 짧은 문자열 (14자리)
client_secret: "secret" 옆 긴 문자열
```

**4단계** EC2 서버 `.env`에 추가:
```bash
ssh -i ~/Desktop/hyoseung.pem ubuntu@3.34.1.212
echo "REDDIT_CLIENT_ID=여기" >> ~/autoCoupon/back/.env
echo "REDDIT_CLIENT_SECRET=여기" >> ~/autoCoupon/back/.env
```

**5단계** 서버 재시작 후 테스트:
```bash
kill $(ps aux | grep 'autoCoupon' | grep node | awk '{print $2}')
cd ~/autoCoupon/back && nohup node dist/main.js >> ~/autoCoupon/back.log 2>&1 &
curl -X POST http://localhost:3002/api/crawler/trigger
```

Reddit 앱 만들면 바로 테스트해볼 수 있어요!
2026-05-08 10:27:33 +09:00
hyoseung930 4514a26575 New task
완료! 추가된 파일 구조는 다음과 같아요:

---

## 추가된 파일

```
autoCoupon/back/src/
├── crawler/
│   ├── crawler.service.ts     ← 핵심 크롤러
│   ├── crawler.controller.ts  ← 수동 트리거 API
│   └── crawler.module.ts
├── wos/
│   └── wos.module.ts          ← WosService export 추가
└── app.module.ts              ← CrawlerModule 등록
```

---

## 동작 방식

```mermaid
flowchart LR
    A[매 정시 크론] --> B[Reddit JSON API 조회]
    B --> C{gift code 언급?}
    C -->|No| D[스킵]
    C -->|Yes| E[정규식으로 코드 추출]
    E --> F{DB 중복?}
    F -->|중복| D
    F -->|신규| G[addCoupon 호출]
    G --> H[전체 유저 자동 지급]
    G --> I[Discord Webhook 알림]
```

---

## 배포 후 설정

**1. `.env`에 Discord Webhook URL 추가 (선택)**
```
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/xxx/yyy
```

**2. 수동 크롤링 테스트**
```bash
curl -X POST http://localhost:3000/api/crawler/trigger
```

**Discord Webhook 만들기**: 디스코드 서버 → 채널 설정 → 연동 → 웹후크 → 새 웹후크 → URL 복사
2026-05-08 10:13:14 +09:00
hyoseung930 15f9fabf96 쿠폰 자동 입력 사이트 작성
모두 완료되었습니다! 🎉

---

## 완료 요약

###  budgetTrack 구축 및 배포 완료

**백엔드** (`budgetTrack/back`) — NestJS, 포트 3008
- **인증**: 이메일 인증 코드 → 회원가입 → JWT 로그인
- **엔티티**: `User`, `EmailVerification`, `Category`, `Account`, `Transaction`, `FixedExpense`, `SavingGoal`
- **핵심 API**: `/api/budget/dashboard` — 오늘 사용 가능 금액 자동 계산
- 기본 카테고리 9개 자동 생성 (가입 시)

**프론트엔드** (`budgetTrack/front`) — Vue 3 + Pinia + Vue Router, **화이트 톤**
| 화면 | 내용 |
|---|---|
| 🏠 홈 대시보드 | **오늘 사용 가능 금액** 대형 표시, 예산 진행률, 빠른 지출 입력 |
| 📝 거래 내역 | 월별 필터, 카테고리별 그룹, 수입/지출 토글 |
| 📊 월간 리포트 | 카테고리별 지출 비율, 일별 지출 바 차트 |
| ⚙️ 설정 | 계정 정보, 고정지출/저축목표/계좌/카테고리 관리 |

**배포**
- **URL**: [https://budgettrack.wageulwageul.com](https://budgettrack.wageulwageul.com)
- PM2 `budget-track-back` 프로세스로 실행 중
- Let's Encrypt SSL 인증서 발급 완료 (만료: 2026-08-04)

> ⚠️ **이메일 발송 설정**: EC2의 `/home/hyoseung/budgetTrack/back/.env` 에서 `MAIL_USER` / `MAIL_PASS` (Gmail 앱 비밀번호)를 입력하면 이메일 인증이 활성화됩니다.
2026-05-06 13:58:30 +09:00
hyoseung930 89d2f1e9f1 쿠폰 자동 입력 사이트 작성
배포 완료. `html`, `body`, `#app`에 `width: 100%`를 추가하여 `margin: 0 auto` 중앙 정렬이 올바르게 동작하도록 수정했습니다. [https://hero.wageulwageul.com](https://hero.wageulwageul.com) 에서 확인해보세요.
2026-04-17 16:41:12 +09:00
hyoseung930 2300c59f43 쿠폰 자동 입력 사이트 작성
배포 완료. 이제 **등록된 유저** 섹션 상단에 검색창이 생겨서 닉네임/FID로 필터링됩니다.
2026-04-17 10:32:18 +09:00
hyoseung930 b651be292e 쿠폰 자동 입력 사이트 작성
배포 완료. 이제:
- **FID 탭**: FID 직접 입력 → WOS API 호출 → DB 저장 → 쿠폰 자동 지급
- **닉네임 탭**: 닉네임 입력 시 DB에서 실시간 검색 → 선택하면 자동으로 FID 조회 및 쿠폰 지급
2026-04-17 10:30:40 +09:00
hyoseung930 dde188877f 쿠폰 자동 입력 사이트 작성
빌드는 성공했습니다. 배포에 SSH 키가 필요합니다. 이전에 어떤 키 파일로 서버에 접속하셨나요? (`.pem` 파일 경로를 알려주세요)
2026-04-17 10:23:01 +09:00
hyoseung930 d4f10d9247 쿠폰 자동 입력 사이트 작성
완료. 이제 모든 쿠폰에 **활성** (초록) 또는 **만료** (보라) 배지가 표시됩니다.
2026-04-17 10:18:52 +09:00
hyoseung930 68c71cb0c4 쿠폰 자동 입력 사이트 작성
완료. 비활성 토글이 제거됐습니다. 이제 쿠폰은 **활성** (기본) 또는 **만료** 두 가지 상태만 있습니다.
2026-04-17 10:17:42 +09:00
hyoseung930 82d245e012 쿠폰 자동 입력 사이트 작성
완료. "최근 유저" → **"등록된 유저 (N명)"** 으로 변경되어 DB에 저장된 모든 FID 유저가 표시됩니다.
2026-04-17 10:07:46 +09:00
hyoseung930 0c114b34c7 쿠폰 자동 입력 사이트 작성
`err_code 40011`도 "이미 수령"으로 처리해서 다음 날 크론에서 스킵되도록 적용됐습니다.
2026-04-17 10:03:30 +09:00
hyoseung930 1628317b26 쿠폰 자동 입력 사이트 작성
로그에서 `err_code=40011, msg=SAME TYPE EXCHANGE.`가 확인됩니다.

**SAME TYPE EXCHANGE**는 WOS에서 **같은 종류(유형)의 보상을 이미 다른 쿠폰으로 받은 경우** 발생합니다.

예: `gogoWOS`와 `VpqG7dDK7` 둘 다 같은 종류의 아이템을 주는 쿠폰이라면, 하나를 이미 받았을 때 나머지는 이 에러가 납니다.

- `40008 RECEIVED` → **이 쿠폰 자체를** 이미 수령
- `40011 SAME TYPE EXCHANGE` → **다른 쿠폰으로** 같은 유형 보상 이미 수령

두 가지 처리 방법이 있습니다:

1. **성공으로 처리** (40008처럼): 어차피 받을 수 없으니 다음에 재시도 안 함
2. **실패로 처리**: 다음날 다시 시도 (다음날 가능성 있는 경우)

어떻게 처리할까요?
2026-04-17 10:03:02 +09:00
hyoseung930 107f1ffe84 쿠폰 자동 입력 사이트 작성
## 수정 내용

- **Sign Error 근본 원인 2가지 수정**:
  1. `now()` 함수가 **초** 단위(`Date.now()/1000`)를 반환하고 있었음 → **밀리초**(`Date.now()`)로 수정 (설계서 명시 사항)
  2. 세션 쿠키 추출 시 쿠키 이름에 'session'이 없으면 빈 값으로 보내고 있었음 → **모든 쿠키를 전체 전송**으로 수정

- **쿠폰 코드 대소문자 유지**: `toUpperCase()` 제거

- **FID localStorage 저장**: 한번 입력하면 다음 방문 시 자동으로 로드되어 바로 쿠폰 지급 시작

- **쿠폰 추가 시 자동 리딤**: 새 쿠폰 등록하면 DB에 저장된 **모든 유저**에게 백그라운드로 자동 지급
2026-04-16 18:29:13 +09:00
hyoseung930 d8fc77789c 쿠폰 자동 입력 사이트 작성
배포 완료. 이제 쿠폰 코드만 입력하면 되고, 목록에도 코드만 표시됩니다.
2026-04-16 18:23:53 +09:00
hyoseung930 14956ac9ec 쿠폰 자동 입력 사이트 작성
## 적용된 변경사항

### 백엔드
- **`wos_coupons` 테이블에 `is_expired` 컬럼 추가** (TypeORM synchronize로 자동 생성)
- **`redeemAll` 로직 개선**:
  - 이미 `성공` / `이미 수령` 로그 있는 쿠폰 → 자동 **스킵** (재지급 안 함)
  - `err_code: 40008` (RECEIVED) → **성공** 처리 (`이미 수령` 메시지)
  - `err_code: 40007` (TIME ERROR) → DB에서 `is_expired = true, is_active = false` 업데이트
- **매일 오전 9시 자동 실행** (`@Cron('0 0 9 * * *')`) - 저장된 모든 유저에게 미수령 쿠폰 자동 지급

### 프론트엔드
- **삭제 버튼 제거**
- 만료된 쿠폰은 **보라색 "만료" 배지** 표시, 토글 버튼 숨김
- 활성 쿠폰 카운트에서 만료 제외
2026-04-16 18:23:16 +09:00
hyoseung930 3da1077ea7 쿠폰 자동 입력 사이트 작성
`/api/redeem`과 `/api/redeem-all` 둘 다 등록됐습니다. 이제 어떤 경로로 호출해도 정상 동작합니다. 다시 시도해 보세요.
2026-04-16 18:10:33 +09:00
hyoseung930 608a904768 쿠폰 자동 입력 사이트 작성
배포 완료됐습니다.

이제 FID 입력란 아래에 **최근 유저** 칩이 표시됩니다. 한 번 조회한 사람은 아바타 + 닉네임 칩으로 저장되어, 다음번엔 클릭 한 번으로 바로 쿠폰을 받을 수 있습니다.
2026-04-16 18:06:00 +09:00
hyoseung930 1b43b874a1 쿠폰 자동 입력 사이트 작성
완료됐습니다. 변경 내용 요약:

---

## 변경 사항

### 버그 수정
- TypeORM `upsert` → MySQL 호환 `findOne + update/save` 방식으로 수정 → 500 에러 해결

### 신규 기능

**쿠폰 관리 (관리자)**
- **https://coupon.wageulwageul.com/admin** 에서 쿠폰 추가/삭제/활성화 토글
- 쿠폰 코드 + 이름 저장 (예: `ABC123` / `신년 이벤트`)

**자동 전체 지급 (사용자)**
- FID 입력 → 조회 → ** 전체 쿠폰 자동 지급** 버튼 클릭
- DB에 등록된 활성 쿠폰을 전부 자동으로 시도
- 결과(쿠폰명 + 코드 + 성공/실패)가 바로 표시되고 DB에 영구 저장
- 이력은 FID별로 조회 시 자동 로드

**사용 흐름:**
1. `/admin` 에서 쿠폰 등록
2. 사용자는 `/` 에서 FID 입력 → 버튼 한 번으로 모든 쿠폰 자동 지급
2026-04-16 18:03:45 +09:00
hyoseung930 5ba2357124 쿠폰 자동 입력 사이트 작성
배포 완료됐습니다. 변경 내용 요약:

---

## 변경 사항

**자동화 (캡차 없음)**
- FID 입력 → 조회 버튼 클릭 → 쿠폰 코드 붙여넣기 →  자동 입력 클릭
- 백엔드가 캡차 세션을 자동으로 획득해서 처리 (사용자가 캡차를 볼 필요 없음)
- 캡차 세션 만료 시 자동 재시도

**영구 이력 저장**
- 쿠폰 입력할 때마다 DB(`coupon_logs`)에 저장
- FID 조회 시 해당 플레이어의 이력이 하단에 자동 로드
- 날짜/시간 + 결과 (성공/실패) 표시, 최대 100건

**https://coupon.wageulwageul.com** 에서 바로 확인하세요.
2026-04-16 17:54:58 +09:00
hyoseung930 3461886b48 쿠폰 자동 입력 사이트 작성
완료됐습니다.

- **원인**: WOS API 서버가 `User-Agent`, `Origin`, `Referer` 헤더 없는 요청을 `403`으로 차단
- **해결**: 브라우저 헤더를 모방한 `COMMON_HEADERS` 추가

이제 **https://coupon.wageulwageul.com** 에서 실제 FID로 조회하면 정상 동작합니다.
2026-04-16 17:50:46 +09:00
hyoseung930 92c8d69ac9 쿠폰 자동 입력 사이트 작성
수정 완료입니다. 결과 확인:

- **이전**: `500 Internal server error` (WOS API가 User-Agent 없어서 403 차단)
- **이후**: `400 오류가 발생했습니다. (40001)` → WOS API 정상 응답 (40001 = 존재하지 않는 FID)

실제 존재하는 FID로 조회하면 정상적으로 유저 정보가 반환됩니다.
2026-04-16 17:50:14 +09:00
hyoseung930 8ea197098d 쿠폰 자동 입력 사이트 작성
완성됐습니다. 전체 구조:

---

## 📁 autoCoupon 프로젝트 구조

### `autoCoupon/back/` (NestJS)
- **[./autoCoupon/back/src/main.ts](./autoCoupon/back/src/main.ts)** — 진입점, CORS 설정, 포트 3000
- **[./autoCoupon/back/src/app.module.ts](./autoCoupon/back/src/app.module.ts)** — TypeORM MySQL 연결 (`kakao` DB)
- **[./autoCoupon/back/src/wos/wos.service.ts](./autoCoupon/back/src/wos/wos.service.ts)** — MD5 sign 생성, 세션(쿠키) 메모리 저장, 쿠폰 순차 입력(1초 딜레이)
- **[./autoCoupon/back/src/wos/wos.controller.ts](./autoCoupon/back/src/wos/wos.controller.ts)** — `POST /api/player`, `/api/captcha`, `/api/redeem`
- **[./autoCoupon/back/src/entities/](./autoCoupon/back/src/entities/)** — `wos_users`, `coupon_logs` 테이블 (TypeORM synchronize: true로 자동 생성)

### `autoCoupon/front/` (Vue 3 + Pinia)
- **[./autoCoupon/front/src/stores/wos.store.ts](./autoCoupon/front/src/stores/wos.store.ts)** — Pinia store (플레이어 조회, 캡차, 쿠폰 제출)
- **[./autoCoupon/front/src/components/UserCard.vue](./autoCoupon/front/src/components/UserCard.vue)** — 닉네임, 용광로 레벨, 아바타 카드
- **[./autoCoupon/front/src/components/CouponInput.vue](./autoCoupon/front/src/components/CouponInput.vue)** — 캡차 + 쿠폰 일괄 입력 (줄바꿈 구분)
- **[./autoCoupon/front/src/components/ResultTable.vue](./autoCoupon/front/src/components/ResultTable.vue)** — 쿠폰별 성공/실패 결과 테이블

---

## 시작 방법

**백엔드:**
```bash
cd autoCoupon/back
cp .env.example .env
npm install
npm run start:dev
```

**프론트엔드:**
```bash
cd autoCoupon/front
npm install
npm run dev
```

> **배포 시** Nginx에서 `/` → Vue `dist/`, `/api` → `localhost:3000` 으로 프록시 설정하면 됩니다.
2026-04-16 17:25:52 +09:00
3 changed files with 354 additions and 1 deletions
+25 -1
View File
@@ -1,5 +1,8 @@
import { Controller, Post, Get, Delete, Patch, Body, Param, Query, HttpCode } from '@nestjs/common';
import { Controller, Post, Get, Delete, Patch, Body, Param, Query, HttpCode, BadRequestException, ForbiddenException } from '@nestjs/common';
import { WosService } from './wos.service';
import axios from 'axios';
const ALLIANCE_API = 'http://localhost:3005/api';
@Controller('api')
export class WosController {
@@ -59,4 +62,25 @@ export class WosController {
async deleteCoupon(@Param('id') id: string) {
return this.wosService.deleteCoupon(Number(id));
}
@Get('alliances')
async getAlliances() {
const { data } = await axios.get(`${ALLIANCE_API}/alliances`);
return data;
}
@Post('alliance-members')
@HttpCode(200)
async getAllianceMembers(@Body() body: { alliance_id: number; pin: string }) {
if (!body.alliance_id || !body.pin) throw new BadRequestException('alliance_id와 pin이 필요합니다');
try {
await axios.get(`${ALLIANCE_API}/alliances/${body.alliance_id}/pending`, { params: { pin: body.pin } });
} catch {
throw new ForbiddenException('PIN이 틀렸습니다');
}
const { data: members } = await axios.get(`${ALLIANCE_API}/members`, { params: { alliance_id: body.alliance_id } });
const wosUsers = await this.wosService.listUsers();
const nicknameSet = new Set(wosUsers.map((u: any) => u.nickname?.toLowerCase()));
return members.map((m: any) => ({ ...m, in_coupon_system: nicknameSet.has(m.nickname?.toLowerCase()) }));
}
}
+326
View File
@@ -0,0 +1,326 @@
<template>
<div class="alliance-panel">
<div class="panel-header" @click="expanded = !expanded">
<span> 연맹원 목록</span>
<span class="toggle-arrow" :class="{ open: expanded }"></span>
</div>
<div v-if="expanded" class="panel-body">
<div v-if="!authed" class="auth-section">
<div class="field-row">
<select v-model="selectedAllianceId" class="select-input" :disabled="loadingAlliances">
<option value="">연맹 선택...</option>
<option v-for="a in alliances" :key="a.id" :value="a.id">
[{{ a.tag }}] {{ a.name }} (서버{{ a.server_number }})
</option>
</select>
</div>
<div class="field-row">
<input v-model="pin" type="password" class="pin-input" placeholder="R5 PIN 입력" @keyup.enter="doAuth" />
<button class="btn-auth" @click="doAuth" :disabled="authing || !selectedAllianceId || !pin.trim()">
{{ authing ? '확인중...' : '확인' }}
</button>
</div>
<div v-if="authError" class="error-msg">{{ authError }}</div>
</div>
<div v-else class="members-section">
<div class="members-header">
<span class="alliance-badge"> {{ currentAlliance?.tag }} {{ currentAlliance?.name }}</span>
<div class="header-actions">
<input v-model="memberSearch" class="search-input" placeholder="닉네임 검색..." />
<button class="btn-logout" @click="logout">나가기</button>
</div>
</div>
<div class="role-filter">
<button
v-for="r in roleFilters"
:key="r"
:class="['role-btn', { active: selectedRole === r }]"
@click="selectedRole = r"
>{{ r }}</button>
</div>
<div class="members-stats">
<span class="stat">전체 {{ filteredMembers.length }}</span>
<span class="stat linked"> 쿠폰 등록: {{ linkedCount }}</span>
<span class="stat unlinked"> 미등록: {{ filteredMembers.length - linkedCount }}</span>
</div>
<div class="member-list">
<div
v-for="m in filteredMembers"
:key="m.id"
:class="['member-row', { linked: m.in_coupon_system }]"
>
<span :class="['role-tag', roleClass(m.role)]">{{ m.role }}</span>
<span class="unit-icon">{{ unitIcon(m.main_unit) }}</span>
<span class="nickname">{{ m.nickname }}</span>
<span class="power">{{ formatPower(m.power) }}</span>
<span v-if="m.in_coupon_system" class="coupon-badge">쿠폰 등록됨</span>
<span v-else class="coupon-miss">미등록</span>
</div>
<div v-if="filteredMembers.length === 0" class="empty">검색 결과가 없습니다</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'
interface Alliance {
id: number
server_number: number
tag: string
name: string
}
interface AllianceMember {
id: number
nickname: string
role: string
main_unit: string
power: number
in_coupon_system: boolean
}
const expanded = ref(false)
const alliances = ref<Alliance[]>([])
const loadingAlliances = ref(false)
const selectedAllianceId = ref<number | ''>('')
const pin = ref('')
const authing = ref(false)
const authError = ref('')
const authed = ref(false)
const currentAlliance = ref<Alliance | null>(null)
const members = ref<AllianceMember[]>([])
const memberSearch = ref('')
const selectedRole = ref('전체')
const roleFilters = ['전체', 'R5', 'R4', 'R3', 'R2', 'R1']
const filteredMembers = computed(() => {
let list = members.value
if (selectedRole.value !== '전체') list = list.filter((m) => m.role === selectedRole.value)
const q = memberSearch.value.trim().toLowerCase()
if (q) list = list.filter((m) => m.nickname.toLowerCase().includes(q))
return list
})
const linkedCount = computed(() => filteredMembers.value.filter((m) => m.in_coupon_system).length)
onMounted(async () => {
loadingAlliances.value = true
try {
const { data } = await axios.get('/api/alliances')
alliances.value = data
} finally {
loadingAlliances.value = false
}
})
async function doAuth() {
if (!selectedAllianceId.value || !pin.value.trim()) return
authing.value = true
authError.value = ''
try {
const { data } = await axios.post('/api/alliance-members', {
alliance_id: selectedAllianceId.value,
pin: pin.value.trim(),
})
members.value = data
currentAlliance.value = alliances.value.find((a) => a.id === selectedAllianceId.value) ?? null
authed.value = true
} catch (e: any) {
authError.value = e?.response?.data?.message || 'PIN이 틀렸거나 오류가 발생했습니다'
} finally {
authing.value = false
}
}
function logout() {
authed.value = false
members.value = []
pin.value = ''
authError.value = ''
currentAlliance.value = null
}
function roleClass(role: string) {
return { R5: 'r5', R4: 'r4', R3: 'r3', R2: 'r2', R1: 'r1' }[role] ?? ''
}
function unitIcon(u: string) {
return { infantry: '🛡', lancer: '🗡', marksman: '🏹', mixed: '⚔️' }[u] ?? '⚔️'
}
function formatPower(p: number) {
if (!p) return '-'
if (p >= 1_000_000_000) return (p / 1_000_000_000).toFixed(1) + 'B'
if (p >= 1_000_000) return (p / 1_000_000).toFixed(1) + 'M'
if (p >= 1_000) return (p / 1_000).toFixed(1) + 'K'
return String(p)
}
</script>
<style scoped>
.alliance-panel {
background: #1e1e2e;
border: 1px solid #2a2a3e;
border-radius: 12px;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
cursor: pointer;
font-weight: 700;
font-size: 0.95rem;
color: #cdd6f4;
user-select: none;
transition: background 0.1s;
}
.panel-header:hover { background: #252535; }
.toggle-arrow { font-size: 0.85rem; color: #6c7086; transition: transform 0.2s; }
.toggle-arrow.open { transform: rotate(180deg); }
.panel-body { padding: 16px; }
.auth-section { display: flex; flex-direction: column; gap: 10px; }
.field-row { display: flex; gap: 8px; }
.select-input {
flex: 1;
background: #181825;
border: 1.5px solid #313244;
color: #cdd6f4;
border-radius: 8px;
padding: 9px 12px;
font-size: 0.88rem;
}
.select-input:focus { outline: none; border-color: #89b4fa; }
.pin-input {
flex: 1;
background: #181825;
border: 1.5px solid #313244;
color: #cdd6f4;
border-radius: 8px;
padding: 9px 12px;
font-size: 0.88rem;
}
.pin-input:focus { outline: none; border-color: #89b4fa; }
.btn-auth {
background: #1e3a8a;
color: #93c5fd;
border: none;
border-radius: 8px;
padding: 9px 16px;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
}
.btn-auth:hover:not(:disabled) { background: #1d4ed8; }
.btn-auth:disabled { opacity: 0.4; cursor: not-allowed; }
.error-msg { color: #f38ba8; font-size: 0.82rem; padding: 4px 0; }
.members-section { display: flex; flex-direction: column; gap: 12px; }
.members-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.alliance-badge { font-weight: 700; color: #89b4fa; font-size: 0.9rem; }
.header-actions { display: flex; gap: 8px; align-items: center; }
.search-input {
background: #181825;
border: 1.5px solid #313244;
color: #cdd6f4;
border-radius: 8px;
padding: 7px 12px;
font-size: 0.85rem;
width: 150px;
}
.search-input:focus { outline: none; border-color: #89b4fa; }
.btn-logout {
background: transparent;
color: #6c7086;
border: 1px solid #313244;
border-radius: 8px;
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
}
.btn-logout:hover { color: #f38ba8; border-color: #f38ba8; }
.role-filter { display: flex; gap: 6px; flex-wrap: wrap; }
.role-btn {
background: #181825;
border: 1.5px solid #313244;
color: #6c7086;
border-radius: 20px;
padding: 4px 12px;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
}
.role-btn:hover { border-color: #89b4fa; color: #89b4fa; }
.role-btn.active { background: #1e3a5f; border-color: #89b4fa; color: #89b4fa; }
.members-stats { display: flex; gap: 12px; font-size: 0.8rem; }
.stat { color: #6c7086; }
.stat.linked { color: #a6e3a1; }
.stat.unlinked { color: #fab387; }
.member-list { display: flex; flex-direction: column; gap: 2px; max-height: 340px; overflow-y: auto; }
.member-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
background: #181825;
font-size: 0.85rem;
transition: background 0.1s;
}
.member-row.linked { background: #0f2010; }
.member-row:hover { background: #252535; }
.member-row.linked:hover { background: #152a15; }
.role-tag {
padding: 2px 7px;
border-radius: 10px;
font-size: 0.72rem;
font-weight: 800;
min-width: 28px;
text-align: center;
}
.role-tag.r5 { background: #3b1f00; color: #fab387; }
.role-tag.r4 { background: #1e1b4b; color: #c4b5fd; }
.role-tag.r3 { background: #1e2d3e; color: #89b4fa; }
.role-tag.r2 { background: #1c2a1c; color: #a6e3a1; }
.role-tag.r1 { background: #252535; color: #6c7086; }
.unit-icon { font-size: 0.9rem; }
.nickname { flex: 1; color: #cdd6f4; font-weight: 600; }
.power { color: #6c7086; font-size: 0.8rem; min-width: 50px; text-align: right; }
.coupon-badge {
background: #132010;
color: #a6e3a1;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.72rem;
font-weight: 700;
}
.coupon-miss {
color: #6c7086;
font-size: 0.72rem;
}
.empty { color: #6c7086; text-align: center; padding: 16px 0; font-size: 0.85rem; }
</style>
+3
View File
@@ -61,6 +61,8 @@
<div class="card admin-card">
<CouponManager />
</div>
<AlliancePanel />
</div>
</div>
</div>
@@ -72,6 +74,7 @@ import { useWosStore } from '../stores/wos.store';
import UserCard from '../components/UserCard.vue';
import CouponInput from '../components/CouponInput.vue';
import CouponManager from '../components/CouponManager.vue';
import AlliancePanel from '../components/AlliancePanel.vue';
const store = useWosStore();
const fidInput = ref('');