일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- ucpc 2023 예선 i번
- router
- Prisma
- 백준 2623번
- JavaScript
- map
- pm2
- 더 흔한 색칠 타일 문제
- ucpc 2023 예선 d번
- HTTP
- 게임 서버 아키텍처
- MySQL
- branch
- Express.js
- 그리디
- PROJECT
- koi 2002 중등부 1번
- Github
- string
- ccw 알고리즘
- Next
- insomnia
- 백준 28298번
- 자바스크립트
- ERD
- ucpc 2024 예선 e번
- localstorage
- MongoDB
- html5
- 백준 28303번
- Today
- Total
dh_0e
[Project] 고도화된 게임 아이템 시뮬레이터 서비스 (개인 과제) 본문
내배캠 5번째 프로젝트는 4번째 프로젝트를 더욱 고도화 시키는 작업이다.
마찬가지로 Node.js와 express를 사용하지만 mongoDB를 사용했던 저번 과제와 달리,
Prisma로 MySQL을 이용하게끔 코드를 수정하며 데이터베이스 2개로 진행해야 했다.
필수 및 도전 과제의 내용으론 로그인/회원가입, 회원 인증 미들웨어, 에러 처리 미들웨어, 아이템 구매/판매, 인벤토리 조회 기능을 추가해야 했으며, 모두 구현한 뒤 아이템 타입(장착 위치)을 추가하여 캐릭터가 아이템을 착용할 때 모자, 갑옷, 바지, 악세서리, 무기 등 타입 별로 하나의 아이템만 장착할 수 있게 제작하였다.
필수 요구 사항 및 도전 요구 사항
0️⃣ 필수 요구 사항: 프로젝트 관리
- .env 파일을 이용해서 민감한 정보(DB 계정 정보, API Key 등)를 관리합니다.
- .gitignore 파일을 생성하여 .env 파일과 node_modules 폴더가 Github에 올라가지 않도록 설정합니다.
- .prettierrc 파일을 생성하여 일정한 코드 포맷팅을 유지할 수 있도록 설정합니다.
1️⃣ 필수 요구 사항: AWS EC2 배포
- 여러분의 완성된 프로젝트를 AWS EC2에 배포해주세요!
- 배포된 IP 주소를 제출해주세요!
2️⃣ 필수 요구 사항: 인증 미들웨어 구현
- Request의 Authorization 헤더에서 JWT를 가져와서 인증 된 사용자인지 확인하는 Middleware를 구현합니다.
- 클라이언트에서는 쿠키로 전달하지 않고 오로지 Authorization 헤더로만 JWT를 전달하니 이 점 유의해주세요!
- 인증에 실패하는 경우에는 알맞은 Http Status Code와 에러 메세지를 반환 해야 합니다.
- Authorization에 담겨 있는 값의 형식이 표준(Bearer <JWT Value>)과 일치하지 않는 경우
- 위와 같은 형식을 통상적으로 베어러-토큰이라고 말을 합니다.
- JWT의 유효기한이 지난 경우
- JWT 검증(JWT Secret 불일치, 데이터 조작으로 인한 Signature 불일치 등)에 실패한 경우
- Authorization에 담겨 있는 값의 형식이 표준(Bearer <JWT Value>)과 일치하지 않는 경우
- 인증에 성공하는 경우에는 req.locals.user와 같은 곳에 인증 사용자 정보를 담고, 다음 동작을 진행합니다.
- API에서 (JWT 인증 필요) 라고 마킹된 부분은 반드시 해당 인증 미들웨어를 거쳐야하니 참고해주세요!
3️⃣ 도전 요구 사항: 데이터베이스 모델링 (2개의 데이터베이스를 사용하기)
- 여러분들은 이제 아래의 API 구현하기 내용을 기반으로 데이터베이스를 직접 모델링하셔야 합니다.
- 우리는 Prisma라는 ORM의 사용법도 배웠고 데이터베이스 내 1:1, 1:N, N:M 관계에 대해서도 배웠죠.
- 이것들을 활용해 다음과 같이 데이터베이스를 모델링 해보도록 합시다.
- 게임 데이터 데이터베이스
- 아이템 테이블
- 우리가 이전 입문 주차 개인과제 때 아이템 생성 API를 통해 아이템을 생성했었죠?
- 이번에도 아이템을 생성하는 API는 있습니다.
- 이제 이 API를 통해 생성된 아이템 정보는 아이템 테이블에 저장되어야 합니다.
- 아이템 테이블
- 유저 데이터베이스
- 계정 테이블
- 캐릭터 테이블
- 하나의 계정은 여러개의 캐릭터를 보유할 수 있어요!
- 캐릭터-인벤토리 테이블
- 캐릭터가 보유는 하고있으나 장착하고 있지 않은 아이템 정보들이 담겨져있겠죠?
- 캐릭터-아이템 테이블
- 이 테이블엔 실제로 캐릭터가 장착한 아이템 정보들이 존재해야 합니다.
- 게임 데이터 데이터베이스
🤔 아니, 튜터님! 아이템 테이블은 왜 다른 데이터베이스에서 별도로 관리하나요? 귀찮게…
- 아이템과 같은 게임 데이터는 유저 데이터가 아니라 게임 그 자체의 데이터라고 생각하시면 됩니다.
- 이러한 실제 게임 데이터는 게임 서버 개발자가 직접 만지지 않습니다.
- 보통은 실제 게임 데이터는 게임 기획자들이 담당하며 그들만의 고유 영역입니다.
- 그리고 유사시에 밸런스 패치를 위해서 해당 게임 데이터 데이터베이스를 통째로 변경하기도 한답니다!
- 따라서, 실제와 유사한 느낌을 내기 위해서 번거롭지만 일부러 데이터베이스를 분리했어요!
- 당장은, 2개의 데이터베이스를 다루는 것이 쉽지 않을 수 있으니 이 부분은 도전 과제로 하도록 하겠습니다!
4️⃣ 필수 요구 사항: API 구현하기
- 신규: 회원가입 API
- 아이디, 비밀번호, 비밀번호 확인, 이름을 데이터로 넘겨서 회원가입을 요청합니다.
- 보안을 위해 비밀번호는 평문(Plain Text)으로 저장하지 않고 해싱된 값을 저장합니다.
- 아래 사항에 대한 유효성 체크를 해야 되며 유효하지 않은 경우 알맞은 HTTP 상태코드와 에러 메세지를 반환해야 합니다.
- 아이디: 다른 사용자와 중복될 수 없으며 오로지 영어 소문자 + 숫자 조합으로 구성이 되어야 합니다.
- 비밀번호: 최소 6자 이상이며, 비밀번호 확인과 일치해야 합니다.
- 회원가입 성공 시, 비밀번호를 제외 한 사용자의 정보를 반환합니다.
- 아이디, 비밀번호, 비밀번호 확인, 이름을 데이터로 넘겨서 회원가입을 요청합니다.
- 신규: 로그인 API
- 아이디, 비밀번호로 로그인을 요청합니다.
- 계정 정보가 일치하지 않을 경우 알맞은 HTTP 상태코드와 에러 메세지를 반환해야 합니다.
- 아이디가 존재하지 않는 경우
- 아이디는 존재하는데 비밀번호가 틀리는 경우
- 로그인 성공 시, 엑세스 토큰을 생성하여 반환합니다.
- 이 때, 엑세스 토큰의 Payload는 로그인 한 계정의 ID를 담고 있어야겠죠?
- 캐릭터 생성 API → (JWT 인증 필요)
- 입문 과제에서 했던 것처럼 이번에도 캐릭터 생성을 하는 API가 필요합니다.
- 다만, 이번에도 캐릭터 명을 request에서 전달 받아주시고 캐릭터 ID를 response로 돌려주세요.
- 이미 존재하는 캐릭터 명으로 캐릭터 생성을 하려고 하면 생성을 못하게 해주세요!
- 다만, 이번에도 캐릭터 명을 request에서 전달 받아주시고 캐릭터 ID를 response로 돌려주세요.
- 캐릭터에는 다음과 같은 스탯이 있습니다.
- health: 500
- power: 100
- 또한, 이번에는 캐릭터 생성을 할 때마다 캐릭터에 기본 게임 머니를 1만원을 제공합니다.
- money: 10000
- 해당 게임 머니로 아이템을 살 수 있으며 각각의 캐릭터는 고유 게임 머니가 있습니다.
- 입문 과제에서 했던 것처럼 이번에도 캐릭터 생성을 하는 API가 필요합니다.
- 캐릭터 삭제 API → (JWT 인증 필요)
- 입문 과제에서 했던 것과 동일하게 캐릭터를 삭제하는 API가 필요합니다.
- 내 계정에 있는 캐릭터가 아니라면 삭제시키면 안되겠죠?
- 캐릭터 상세 조회 API
- 입문 과제에서 했던 것과 동일하게 캐릭터를 상세 조회 API가 필요합니다.
- 다만, 내 캐릭터를 상세 조회시에는 현재 캐릭터가 갖고있는 게임 머니까지 조회가 되어야 합니다.
- 로그인 하지 않았거나 다른 유저가 내 캐릭터를 조회하는 경우
- { "name": "호호아줌마", "health": 500, "power": 100 }
- 내가 내 캐릭터를 조회하는 경우
- { "name": "호호아줌마", "health": 500, "power": 100, "money": 10000 }
- 아이템 생성 API
- 아이템 코드, 아이템 명, 아이템 능력 및 아이템 가격을 request에서 전달 받기
- 이 때, 아이템 능력은 JSON 포맷으로 전달해주시면 됩니다.
- request의 body 예시
- { "item_code": 3, "item_name": "파멸의 반지", "item_stat": { "health": 20, "power": 2 }, "item_price": 500 }
- 아이템 코드, 아이템 명, 아이템 능력 및 아이템 가격을 request에서 전달 받기
- 아이템 수정 API
- 입문 과제에서 했던 것과 동일하게 아이템을 수정하는 API가 필요합니다.
- 아이템 가격은 수정을 할 수 없으니 참고해주세요.
- 아이템 목록 조회 API
- 아이템 코드, 아이템 명, 아이템 가격 내용만 조회
- 아이템 생성 API를 통해 생성된 모든 아이템들이 목록으로 조회가 될 수 있어야 합니다.
- response 예시
- [ { "item_code": 1, "item_name": "막대기", "item_price": 10 }, { "item_code": 2, "item_name": "너덜너덜한 고무신", "item_price": 15 }, { "item_code": 3, "item_name": "파멸의 반지_리뉴얼", "item_price": 200 } ]
- 아이템 상세 조회 API
- 아이템 코드를 URI의 parameter로 전달 받아 아이템 코드, 아이템 명, 아이템 능력, 아이템 가격을 조회
- response 예시
- { "item_code": 3, "item_name": "파멸의 반지", "item_stat": { "health": 20, "power": 2 }, "item_price": 200 }
🔥 도전 요구 사항: 아이템 구입/판매 기능 및 아이템 실제로 탈/장착해보기!
- 신규: 아이템 구입 API → (JWT 인증 필요)
- 아이템 구입은 무한하게 할 수 없으며 우리가 가진 게임 머니 안에서 아이템을 구입할 수 있습니다.
- 아이템 구입 이후에는 게임 머니가 당연히 감소를 해야겠죠?
- 구입할 내 캐릭터의 ID를 URI의 parameter로 전달받고 구입하고 싶은 아이템의 코드와 수량을 request에서 전달을 받도록 할게요.
- request의 body 예시
- 당연한 얘기겠지만 내가 사고싶은 총 아이템의 가격이 게임 머니보다 크면 구입을 할 수 없겠죠?
- 또한, 존재하지 않는 item_code가 있어도 유효하지 않은 아이템 구입이라고 거절을 당해야 합니다.
- 가격은 request의 body로 받지 않습니다. 아이템 생성 시 입력된 가격을 따라가도록 합시다.
- [ { "item_code": 1, "count": 2, }, { "item_code": 3, "count": 1, } ]
- 구입 성공 시 response로는 변경된 게임 머니 잔액을 리턴해주도록 합시다!
- 또한, 구입에 성공을 하게 되면 이 아이템들은 곧바로 장착되는 것이 아니라 인벤토리로 가야합니다.
- 신규: 아이템 판매 API → (JWT 인증 필요)
- 유저가 판매하고 싶은 아이템들을 처분하는 API며 아이템 구매 API와 같은 형식으로 작성하시면 됩니다.
- 게임 아이템들은 팔게 되면 원가의 60% 가격으로 정산을 받아야하고 판매 성공 시 response로는 변경된 게임 머니 잔액을 리턴해주도록 합시다!
- 아이템 판매 시에는 인벤토리에 있는 아이템만 팔 수 있도록 하며 장착중인 아이템은 팔 수 없습니다.
- 신규: 캐릭터가 보유한 인벤토리 내 아이템 목록 조회 API → (JWT 인증 필요)
- 인벤토리 목록을 조회할 내 캐릭터의 ID를 URI의 parameter로 전달 받기
- response 예시
- [ { "item_code": 1, "item_name": "막대기", "count": 3 }, { "item_code": 3, "item_name": "파멸의 반지", "count": 2 } ]
- 신규: 캐릭터가 장착한 아이템 목록 조회 API
- 장착된 아이템 목록을 조회할 아무 캐릭터의 ID를 URI의 parameter로 전달 받기
- response 예시
- 기본적으로는 아무런 장착을 하지 않았다면 빈 배열로 던져질 것입니다.
- 게임 내에서 유저가 장착한 아이템을 보는 것은 다른 게임에서도 자유롭게 할 수 있으니 이 API는 인증 미들웨어를 거치지 않아도 됩니다.
- [ { "item_code": 1, "item_name": "막대기", }, { "item_code": 3, "item_name": "파멸의 반지", } ]
- 신규: 아이템 장착 API → (JWT 인증 필요)
- 아이템을 장착할 내 캐릭터의 ID를 URI의 parameter로 전달 받기
- 장착할 아이템 코드를 request에서 전달 받기
- 현재 캐릭터 인벤토리에 존재하지 않는 아이템이 아니라면 없는 아이템이라고 장착이 거부되어야 합니다!
- 이 때, 이미 장착한 아이템(아이템 코드 기반으로 구분할 수 있겠죠)을 또 장착하려고 하면 이미 장착된 아이템이라고 장착이 거부되어야 합니다!
- 아이템 코드가 같은 아이템이 다수 인벤토리에 있어도 장착은 1개만 할 수 있다는 얘기!
- 매우 중요: 아이템 장착을 하게 되면 캐릭터의 스탯이 올라가야 합니다!
- 아이템 장착에 성공하면 기존 캐릭터의 스탯을 직접적으로 변경해주도록 해요.
- 예시
- BEFORE.
- 캐릭터 스탯: { health: 500, power: 100 }
- “파멸의 반지”를 장착!
- AFTER.
- 캐릭터 스탯: { health: 520, power: 102 }
- BEFORE.
- 캐릭터 조회 API를 사용할 때 변경된 캐릭터 스탯으로 나타나야겠죠?
- 또한, 캐릭터-아이템 테이블에서 해당 아이템 정보를 추가해야 됩니다.
- 그리고, 캐릭터-인벤토리 테이블에서 해당 아이템 정보는 변경 혹은 삭제가 되어야겠죠.
- 인벤토리에 1개밖에 없던 아이템을 장착시에는 아이템 정보를 삭제
- n(n ≥ 2)개 이상 있던 아이템을 장착시에는 아이템 개수를 n-1 개로 변경
- 신규: 아이템 탈착 API → (JWT 인증 필요)
- 아이템을 장착할 내 캐릭터의 ID를 URI의 parameter로 전달 받기
- 탈착할 아이템 코드를 request에서 전달 받기
- 이 때, 장착 되지 않은 아이템을 탈착하려고 하면 장착 되어있지 않은 아이템이라고 탈착이 거부되어야 합니다!
- 매우 중요: 아이템 탈착을 하게 되면 캐릭터의 스탯이 떨어져야 합니다!
- 아이템 탈착에 성공하면 기존 캐릭터의 스탯을 직접적으로 변경해주도록 해요.
- 예시
- BEFORE.
- 캐릭터 스탯: { health: 520, power: 102 }
- “파멸의 반지”를 탈착!
- AFTER.
- 캐릭터 스탯: { health: 500, power: 100 }
- BEFORE.
- 캐릭터 조회 API를 사용할 때 변경된 캐릭터 스탯으로 나타나야겠죠?
- 또한, 캐릭터-아이템 테이블에서 해당 아이템 정보를 삭제해야 됩니다.
- 그리고, 캐릭터-인벤토리 테이블에서 해당 아이템 정보는 변경 혹은 추가가 되어야겠죠.
- 인벤토리에 존재하지 않은 아이템을 탈착시에는 아이템 정보를 추가
- n(n ≥ 1)개 이상 있던 아이템을 탈착시에는 아이템 개수를 n+1 개로 변경
- 신규: 게임 머니를 버는 API → (JWT 인증 필요)
- 캐릭터마다 초기에 게임 머니 1만원을 지급하는데 이걸론 뭔가 좀 아쉽습니다.
- 아이템을 더 구입하고 싶다면 게임 머니를 버는 해당 API를 불러서 100원씩 벌게끔 해봅시다!
- 돈을 벌 내 캐릭터의 ID를 URI의 parameter로 전달 받기만 하면 해당 캐릭터의 잔액 게임 머니를 100원씩 늘려주고 response로 변경된 잔액 게임 머니를 전달해주세요.
Code
app.js: 라우터 router, usersRouter, characterRouter, itemsRouter, storeRouter, mountingRouter를 미들웨어로 연결 (URL이 /api로 입력되었을 때), 에러 처리 미들웨어 또한 연결해주며 포트를 3000으로 설정하여 서버를 열어줌
const express = require("express");
// const cookieParser = require("cookie-parser"); // 배포 단계에서 주석처리
const { router: charactersRouter } = require("./routes/characters.router.js");
const { router: itemsRouter } = require("./routes/items.router.js");
const { router: usersRouter } = require("./routes/users.router.js");
const { router: storeRouter } = require("./routes/store.router.js");
const { router: mountingRouter } = require("./routes/mounting.router.js");
const errorHandlingMiddleware = require("./middleware/error-handling.middleware.js");
const app = express();
const PORT = 3000;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
const router = express.Router();
router.get("/", (req, res) => {
return res.json({ message: "Hello, welcome to Item simulator!" });
});
app.use("/api", [
router,
usersRouter,
charactersRouter,
itemsRouter,
storeRouter,
mountingRouter,
]); // use는 미들웨러를 사용해주게 함 /api 경로로 접근하는 경우에만 json 미들웨어를 거친 뒤 router로 연결되게 함
app.use(errorHandlingMiddleware);
app.listen(PORT, () => {
console.log(PORT, "포트로 서버가 열렸어요");
});
user.schema.prisma: userDB의 틀로, prisma의 schema 파일이며 다음과 같은 명령어로 db를 만들며 generate 함
npx prisma db push --schema=./prisma/user.schema.prisma
generator client {
provider = "prisma-client-js"
output = "./generated/client1"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model Users{
userId Int @id @default(autoincrement()) @map("userId")
email String @map("email") @unique
password String @map("password")
name String @map("name")
created_at DateTime @default(now()) @map("created_at")
updated_at DateTime @updatedAt @map("updated_at")
Characters Characters[]
@@map("Users")
}
model Characters{
characterId Int @id @default(autoincrement()) @map("characterId")
UserId Int @map("UserId")
name String @map("name")
health Int @map("health")
power Int @map("power")
money Int @map("money")
created_at DateTime @default(now()) @map("created_at")
updated_at DateTime @updatedAt @map("updated_at")
User Users @relation (fields: [UserId], references: [userId], onDelete: Cascade)
Inventories Inventories?
MountedItems MountedItems?
@@map("Characters")
}
model Inventories{
inventoryId Int @id @default(autoincrement()) @map("inventoryId")
CharacterId Int @unique @map("CharacterId")
items Json? @map("items")
Character Characters @relation (fields: [CharacterId], references: [characterId], onDelete: Cascade)
@@map("Inventories")
}
model MountedItems{
mountedItemId Int @id @default(autoincrement()) @map("mountedItemId")
CharacterId Int @unique @map("CharacterId")
items Json? @map("items")
mountingLocation Json @map("mountingLocation")
Character Characters @relation (fields: [CharacterId], references: [characterId], onDelete: Cascade)
@@map("MountedItems")
}
item.schema.prisma: itemDB의 틀로, prisma의 schema 파일이며 다음과 같은 명령어로 db를 만들며 generate 함
npx prisma db push --schema=./prisma/user.schema.prisma
generator client {
provider = "prisma-client-js"
output = "./generated/client2"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL2")
}
model Items{
itemId Int @id @default(autoincrement()) @map("itemId")
itemCode Int @map("itemCode")
name String @map("name")
itemStat Json @map("itemStat")
cost Int @map("cost")
itemType String @map("itemType")
@@map("Items")
}
index.js: Prisma Client들을 읽어오며 각 Client를 userPrisma, itemPrisma로 exports함
const {
PrismaClient: PrismaClient1,
} = require("../../../prisma/generated/client1");
const {
PrismaClient: PrismaClient2,
} = require("../../../prisma/generated/client2");
const userPrisma = new PrismaClient1({
// Prisma를 이용해 데이터베이스를 접근할 때, SQL을 출력해줍니다.
log: ["query", "info", "warn", "error"],
// 에러 메시지를 평문이 아닌, 개발자가 읽기 쉬운 형태로 출력해줍니다.
errorFormat: "pretty",
});
const itemPrisma = new PrismaClient2({
// Prisma를 이용해 데이터베이스를 접근할 때, SQL을 출력해줍니다.
log: ["query", "info", "warn", "error"],
// 에러 메시지를 평문이 아닌, 개발자가 읽기 쉬운 형태로 출력해줍니다.
errorFormat: "pretty",
});
exports.userPrisma = userPrisma;
exports.itemPrisma = itemPrisma;
users.router.js: 로그인 / 회원가입 api가 작성된 라우터로 개발할 땐 cookie로 토큰을 빠르게 전달하여 api를 테스트하기 편하게 하였고, 배포할 때 token을 response에 첨부하여 토큰을 headers에게 넣도록 변경하였다.
const express = require("express");
const jwt = require("jsonwebtoken");
const { userPrisma } = require("../utils/prisma/index.js");
const bcrypt = require("bcrypt");
const router = express.Router();
const Users = userPrisma.users;
/** 사용자 회원가입 API **/
router.post("/sign-up", async (req, res, next) => {
try {
const { email, password, name } = req.body;
const isExistUser = await Users.findFirst({
where: { email },
});
if (isExistUser) {
console.log(isExistUser.email);
return res
.status(409)
.json({ errorMessage: "이미 존재하는 이메일입니다." });
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await Users.create({
data: {
email,
password: hashedPassword,
name,
},
});
return res
.status(201)
.json({ message: "회원가입이 완료되었습니다.", user });
} catch (err) {
next(err);
}
});
/* 로그인 API */
router.post("/sign-in", async (req, res, next) => {
const { email, password } = req.body;
const user = await Users.findFirst({ where: { email } });
if (!user) {
return res
.status(401)
.json({ errorMessage: "존재하지 않는 이메일입니다." });
}
if (!(await bcrypt.compare(password, user.password))) {
return res
.status(401)
.json({ errorMessage: "비밀번호가 일치하지 않습니다." });
}
const token = jwt.sign(
{
userId: user.userId,
},
process.env.JWT_KEY,
{ expiresIn: "1h" }
);
// res.cookie("authorization", `Bearer ${token}`); // 일단 쿠키로 전달하고 제출 전에 주석처리
return res.status(200).json({
message: "로그인 성공했습니다.",
authorization: `Bearer ${token}`,
});
});
/* 회원 탈퇴 API */
exports.router = router;
character.router.js: 캐릭처 생성, 조회, 삭제 api가 있으며 인증 미들웨어인 authMiddleware를 먼저 실행하여 사용자가 인증되면 화살표 함수가 실행되도록 하였다.
const express = require("express");
const jwt = require("jsonwebtoken");
const { userPrisma } = require("../utils/prisma/index.js");
const authMiddleware = require("../middleware/auth.middleware");
const dotenv = require("dotenv");
const router = express.Router();
const Characters = userPrisma.characters;
const MountedItems = userPrisma.mountedItems;
/* 캐릭터 생성 api */
router.post("/character", authMiddleware, async (req, res, next) => {
const { name } = req.body;
const user = req.user;
console.log(user);
const sameName = await Characters.findFirst({
// name과 동일한 이름의 character가 User에게 있는지 찾음
where: { name, UserId: user.userId },
});
if (sameName) {
// 있다면 에러 메시지 전송
return res
.status(400)
.json({ errorMessage: "동일한 이름을 가진 캐릭터가 존재합니다" });
}
const newCharacter = await Characters.create({
// 캐릭터 추가
data: {
UserId: user.userId,
name,
health: 500,
power: 100,
money: 10000,
},
});
// Mountings에도 캐릭터 정보 추가
await MountedItems.create({
data: {
CharacterId: newCharacter.characterId,
items: [],
mountingLocation: {
hat: false,
armor: false,
pants: false,
shoes: false,
accessories: false,
weapon: false,
},
},
});
return res
.status(201)
.json({ name, newCharacterId: newCharacter.characterId });
});
/* 캐릭터 전체 조회 api */
router.get("/character/", async (req, res, next) => {
const charactersList = await Characters.findMany({
select: {
characterId: true,
UserId: true,
name: true,
},
orderBy: [
{
UserId: "asc", // 먼저 UserId로 정렬
},
{
characterId: "asc", // UserId가 같을 경우 characterId로 정렬
},
],
});
return res.status(200).json({ charactersList });
});
/* 캐릭터 상세 조회 api */
router.get("/character/:characterId", async (req, res, next) => {
const characterId = +req.params.characterId; // parameter 가져오기
let userCheck = () => {
// 로그인 하지 않았으면 0 아니면 id return
const { authorization } = req.headers;
// const { authorization } = req.cookies; // 일단 쿠키로 전달받고 제출전에 headers로 변경
if (!authorization) return 0;
const [tokenType, token] = authorization.split(" ");
if (tokenType !== "Bearer") return 0;
const decodedToken = jwt.verify(token, process.env.JWT_KEY);
const userId = +decodedToken.userId;
if (!userId) {
return 0;
}
return userId;
};
const ID = userCheck();
const character = await Characters.findFirst({
// characterId가 같은 객체 찾기
where: { characterId },
});
if (!character) {
// 없으면 에러 메시지
return res.status(404).json({ errorMessage: "조회할 캐릭터가 없습니다." });
}
if (!ID || character.UserId !== ID) {
const { name, health, power } = character; // 로그인X or 타 사용자 캐릭터 조회
return res.status(200).json({ name, health, power });
}
const { name, health, power, money } = character; // 본인 캐릭터를 조회하는 경우
return res.status(200).json({ name, health, power, money });
});
/* 캐릭터 삭제 api */
router.delete(
"/character/:characterId",
authMiddleware,
async (req, res, next) => {
const user = req.user;
const characterId = +req.params.characterId; // parameter 가져오기
const character = await Characters.findFirst({
// characterId, userId가 같은 객체 찾기
where: {
characterId: characterId,
},
});
if (!character) {
// 없으면 에러 메시지
return res
.status(404)
.json({ errorMessage: "삭제할 캐릭터가 없습니다." });
}
const name = character.name;
if (character.UserId !== user.userId) {
// 다른 유저의 캐릭터 삭제 시도
return res
.status(403)
.json({ errorMessage: "다른 사용자의 캐릭터입니다." });
}
await Characters.delete({
where: {
characterId: characterId,
UserId: user.userId,
},
}); // characterId, userId가 같은 객체 삭제
// Mountings에도 캐릭터 정보 삭제
// await Mountings.deleteMany({ character_id: deleteId });
return res
.status(200)
.json({ completeMessage: `캐릭터 '${name}'을 삭제하였습니다.` });
}
);
exports.router = router;
items.router.js: 아이템 생성, 조회(전체/상세), 수정 api가 작성된 라우터 구현
const express = require("express");
const { itemPrisma, userPrisma } = require("../utils/prisma/index.js");
const router = express.Router();
const Items = itemPrisma.items;
const MountedItems = userPrisma.mountedItems;
/* 아이템 생성 api */
router.post("/item", async (req, res, next) => {
const { item_name, item_code, item_stat, item_price, item_type } = req.body;
const sameName = await Items.findFirst({
where: {
name: item_name,
},
}); // item_name과 동일한 이름의 item이 있는지 찾음
if (sameName) {
// 있다면 에러 메시지 전송
return res
.status(400)
.json({ errorMessage: "동일한 이름의 아이템이 존재합니다" });
}
const sameCode = await Items.findFirst({
where: {
itemCode: item_code,
},
}); // item_name과 동일한 이름의 item이 있는지 찾음
if (sameCode) {
// 있다면 에러 메시지 전송
return res
.status(400)
.json({ errorMessage: "동일한 item_code의 아이템이 존재합니다" });
}
// itemType이 유효한 지 검사
console.log(item_type);
if (
!["hat", "armor", "pants", "shoes", "accessories", "weapon"].includes(
item_type.toLowerCase()
)
) {
return res
.status(400)
.json({ errorMessage: "아이템 타입이 유효하지 않습니다" });
}
const newItem = await Items.create({
data: {
itemCode: item_code,
name: item_name,
itemStat: item_stat,
cost: item_price,
itemType: item_type.toLowerCase(),
},
});
return res.status(201).json({ newItem });
});
/* 아이템 목록 조회 api */
router.get("/store", async (req, res, next) => {
let itemList = await Items.findMany({
select: {
itemCode: true,
name: true,
cost: true,
},
orderBy: {
itemId: "asc", // itemId로 정렬
},
});
itemList = itemList.map((item) => ({
// 출력 형식 변경
item_code: item.itemCode,
item_name: item.name,
item_price: item.cost,
}));
return res.status(200).json(itemList);
});
/* 아이템 상세 조회 api */
router.get("/item/:itemCode", async (req, res, next) => {
const getCode = +req.params.itemCode; // parameter 가져오기
const item = await Items.findFirst({
// itemCode가 같은 객체 찾기
where: { itemCode: getCode },
});
if (!item) {
// 없으면 에러 메시지
return res.status(404).json({ errorMessage: "조회할 아이템이 없습니다." });
}
const { itemCode, name, itemStat, cost, itemType } = item; // 출력할 정보들 구조 분해 할당
return res.status(200).json({
item_code: itemCode,
item_name: name,
item_stat: itemStat,
item_price: cost,
item_type: itemType,
});
});
/* 아이템 수정 api */
router.patch("/item/:itemCode", async (req, res, next) => {
const { item_name, item_stat } = req.body; // 수정할 정보 갸져오기
const itemCode = +req.params.itemCode; // parameter(바꿀 아이템 code) 가져오기
const item = await Items.findFirst({
// itemCode가 같은 객체 찾기
where: { itemCode: itemCode },
});
if (!item) {
// 없으면 에러 메시지
return res.status(404).json({ errorMessage: "수정할 아이템이 없습니다." });
}
// 장착하고 있는 캐릭터가 있다면 에러 메시지 출력
let mounting = false;
const mount = await MountedItems.findMany();
mount.forEach((obj) => {
const mounted = obj.items.find(function (arr) {
// 장착되어있는 item_code 아이템 불러오기
return arr.itemCode == itemCode;
});
if (mounted) {
mounting = true;
return false;
}
});
if (mounting) {
return res
.status(400)
.json({ errorMessage: "아이템이 장착되어 있어 수정할 수 없습니다." });
}
await Items.update({
data: {
name: item_name,
itemStat: item_stat,
},
where: {
itemId: item.itemId,
itemCode,
},
});
return res.status(200).json({ completeMessage: "수정이 완료되었습니다." });
});
exports.router = router;
store.router.js: 아이템 구매/판매, 인벤토리 조회, 100원 획득 api가 구현된 라우터
const express = require("express");
const { itemPrisma, userPrisma } = require("../utils/prisma/index.js");
const authMiddleware = require("../middleware/auth.middleware");
const router = express.Router();
const Items = itemPrisma.items;
const Characters = userPrisma.characters;
const Inventories = userPrisma.inventories;
/* 아이템 구매 API */
router.patch("/store/buy/:characterId", authMiddleware, async (req, res) => {
const characterId = +req.params.characterId;
const character = await Characters.findFirst({
// characterId와 userId가 일치하는 character 가져오기
where: {
characterId,
UserId: req.user.userId,
},
});
if (!character) {
return res.status(404).json({ errorMessage: "캐릭터를 찾을 수 없습니다." });
}
const body = req.body;
let totalCost = character.money,
updatedItems = {},
itemsPurchased = 0;
for (let index in body) {
// 여러 아이템을 살 수 있으므로 배열로 들어온 body값을 for..in 문으로 탐색
const { item_code, count } = body[index];
if (count === 0) continue;
const item = await Items.findFirst({
// itemCode가 같은 item 찾기
where: {
itemCode: item_code,
},
});
if (!item) {
return res.status(404).json({
errorMessage: `item_code가 ${item_code}인 아이템을 찾을 수 없습니다.`,
});
}
totalCost -= item.cost * count;
updatedItems[item_code] = count;
itemsPurchased += count;
}
if (!itemsPurchased) {
// 아이템 구매 개수가 0개일 때
return res
.status(400)
.json({ errorMessage: "아이템을 1개 이상 구매해주세요." });
}
if (totalCost < 0) {
// 아이템 비용이 잔액보다 적을 때
return res.status(400).json({ errorMessage: "잔액이 부족합니다." });
}
await Characters.update({
// characters db에 업데이트
data: {
money: totalCost,
},
where: {
characterId,
UserId: req.user.userId,
},
});
// inventories에 추가
const inventory = await Inventories.findFirst({
where: {
CharacterId: characterId,
},
});
if (inventory) {
// 기존 인벤토리에 새 아이템 추가
for (let key in updatedItems) {
if (inventory.items[key] !== undefined) {
// inventory에 key(itemCode)값이 있다면 value(개수)에 count를 더함
inventory.items[key] += updatedItems[key];
} else {
inventory.items[key] = updatedItems[key];
}
}
await Inventories.update({
where: {
CharacterId: characterId,
},
data: {
items: inventory.items,
},
});
} else {
// 인벤토리가 존재하지 않으면 새로 생성
await Inventories.create({
data: {
CharacterId: characterId,
items: updatedItems,
},
});
}
return res.status(200).json({
message: `아이템 ${itemsPurchased}개를 성공적으로 구매하였습니다.`,
remainingBalance: totalCost,
}); // 잔액을 함께 출력
});
/* 아이템 판매 API */
router.patch("/store/sell/:characterId", authMiddleware, async (req, res) => {
const characterId = +req.params.characterId;
const character = await Characters.findFirst({
// characterId와 userId가 일치하는 character 가져오기
where: {
characterId,
UserId: req.user.userId,
},
});
if (!character) {
return res.status(404).json({ errorMessage: "캐릭터를 찾을 수 없습니다." });
}
const body = req.body;
let totalCost = character.money,
inventory = await Inventories.findFirst({
where: { CharacterId: characterId },
}),
itemsPurchased = 0;
if (!inventory) {
return res
.status(400)
.json({ errorMessage: "인벤토리에 아이템이 존재하지 않습니다!" });
}
for (let index in body) {
// 여러 아이템을 팔 수 있으므로 배열로 들어온 body값을 for..in 문으로 탐색
const { item_code, count } = body[index];
if (count === 0) continue;
const item = await Items.findFirst({
// itemCode가 같은 item 찾기
where: {
itemCode: item_code,
},
});
if (!item) {
return res.status(404).json({
errorMessage: `item_code가 ${item_code}인 아이템을 찾을 수 없습니다.`,
});
}
// 팔 아이템이 부족하거나 없을 때
totalCost += Math.floor(item.cost * count * 0.6); // 60% 가격 적용 후 소수점 내림
if (
inventory.items[item_code] === undefined ||
inventory.items[item_code] < count
) {
return res.status(400).json({
errorMessage: `item_code가 ${item_code}인 아이템이 ${count}개 이하입니다.`,
});
} else inventory.items[item_code] -= count;
itemsPurchased += count;
}
if (!itemsPurchased) {
// 아이템 판매 개수가 0개일 때
return res
.status(400)
.json({ errorMessage: "아이템을 1개 이상 판매해주세요." });
}
// characters db에 업데이트
await Characters.update({
data: {
money: totalCost,
},
where: {
characterId,
UserId: req.user.userId,
},
});
// Inventories db에 업데이트
await Inventories.update({
where: {
CharacterId: characterId,
},
data: {
items: inventory.items,
},
});
return res.status(200).json({
message: `아이템 ${itemsPurchased}개를 성공적으로 판매하였습니다.`,
remainingBalance: totalCost,
});
});
/* 인벤토리 조회 API */
router.get("/inventory/:characterId", authMiddleware, async (req, res) => {
const characterId = +req.params.characterId;
const character = await Characters.findFirst({
// characterId와 userId가 일치하는 character 가져오기
where: {
characterId,
UserId: req.user.userId,
},
});
if (!character) {
return res.status(404).json({ errorMessage: "캐릭터를 찾을 수 없습니다." });
}
// characterId와 일치하는 inventory 가져오기
const inventory = await Inventories.findFirst({
where: { CharacterId: characterId },
});
if (!inventory) {
return res
.status(400)
.json({ errorMessage: "인벤토리에 아이템이 존재하지 않습니다!" });
}
// 출력 형식 변경
let output = [];
let itemList = inventory.items;
for (let key in itemList) {
const item = await Items.findFirst({
where: {
itemCode: +key,
},
});
const obj = {
item_code: key,
item_name: item.name,
count: itemList[key],
};
output.push(obj);
}
return res.status(200).json(output);
});
router.patch("/getMoney/:characterId", authMiddleware, async (req, res) => {
const characterId = +req.params.characterId;
const character = await Characters.findFirst({
// characterId와 userId가 일치하는 character 가져오기
where: {
characterId,
UserId: req.user.userId,
},
});
if (!character) {
return res.status(404).json({ errorMessage: "캐릭터를 찾을 수 없습니다." });
}
// 100원 추가해서 db에 업데이트
character.money += 100;
await Characters.update({
data: {
money: character.money,
},
where: {
characterId,
UserId: req.user.userId,
},
});
return res
.status(200)
.json({ message: "100원을 획득하였습니다.", "현재 잔액": character.money });
});
exports.router = router;
mounting.router.js: 장착된 아이템 조회, 아이템 장착/탈착 api가 구현된 라우터로 아이템 타입 기능이 추가되었다.
const express = require("express");
const { itemPrisma, userPrisma } = require("../utils/prisma/index.js");
const authMiddleware = require("../middleware/auth.middleware");
const router = express.Router();
const Items = itemPrisma.items;
const Characters = userPrisma.characters;
const Inventories = userPrisma.inventories;
const MountedItems = userPrisma.mountedItems;
/* 장착된 아이템 조회 api */
router.get("/mounting/:characterId", async (req, res, next) => {
const characterId = +req.params.characterId; // parameter 가져오기
const mounting = await MountedItems.findFirst({
// character_id가 같은 객체 찾기
where: {
CharacterId: characterId,
},
});
if (!mounting) {
// 없으면 에러 메시지
return res.status(404).json({ errorMessage: "조회할 캐릭터가 없습니다." });
}
// itemCode 기준으로 정렬
mounting.items.sort((a, b) => {
if (a.itemCode < b.itemCode) return -1;
if (a.itemCode > b.itemCode) return 1;
return 0;
});
return res.status(200).json(mounting.mountingLocation);
});
/* 아이템 장착 api */
router.patch("/mounting/:characterId", authMiddleware, async (req, res) => {
const characterId = +req.params.characterId; // parameter 가져오기
const character = await Characters.findFirst({
// characterId와 userId가 일치하는 character 가져오기
where: {
characterId,
UserId: req.user.userId,
},
});
if (!character) {
return res
.status(404)
.json({ errorMessage: "아이템을 장착할 캐릭터가 없습니다." });
}
const mounting = await MountedItems.findFirst({
// characterId가 같은 객체 찾기 (MountedItems 모듈에서)
where: { CharacterId: +characterId },
});
// 아이템 코드가 존재하는지 검사
const { item_code } = req.body; // 장착할 아이템 갸져오기
const item = await Items.findFirst({
// itemCode가 같은 객체 찾기
where: { itemCode: item_code },
});
if (!item) {
// 없으면 에러 메시지
return res
.status(404)
.json({ errorMessage: "장착할 아이템이 존재하지 않습니다." });
}
// 같은 타입 아이템이 장착되어 있는지 확인
let mountingLocation = mounting.mountingLocation;
const sameType = mountingLocation[item.itemType];
if (sameType) {
// 이미 장착되어 있다면 에러 출력
return res
.status(400)
.json({ errorMessage: "동일한 아이템 타입이 장착되어 있습니다." });
}
// 아이템이 inventory에 존재하는지 검사
const inventory = await Inventories.findFirst({
// characterId와 일치하는 inventory 가져오기
where: { CharacterId: characterId },
});
if (
!inventory ||
inventory.items[item_code] === undefined ||
inventory.items[item_code] < 1
) {
return res
.status(400)
.json({ errorMessage: "인벤토리에 아이템이 존재하지 않습니다." });
}
// MountedItems에 아이템 추가
const { name, itemStat, itemType } = item;
mountedItems.push({
// item 목록에 장착할 item 추가
itemCode: item_code,
name,
});
mountingLocation[itemType] = name;
await MountedItems.update({
// db에 업데이트
data: {
items: mountedItems,
mountingLocation,
},
where: {
CharacterId: characterId,
},
});
// 아이템 장착으로 인한 캐릭터 스탯 변경
let { health, power } = character;
if (itemStat.health) health += itemStat.health; // health를 올려주면 health 증가
if (itemStat.power) power += itemStat.power; // power를 올려주면 power 증가
await Characters.update({
// db에 업데이트
data: {
health,
power,
},
where: {
characterId,
UserId: req.user.userId,
},
});
// inventory에서 아이템 삭제
inventory.items[item_code]--;
await Inventories.update({
data: {
items: inventory.items,
},
where: { CharacterId: characterId },
});
return res
.status(200)
.json({ completeMessage: "아이템 장착이 완료되었습니다." });
});
/* 아이템 탈착 api */
router.patch("/detachable/:characterId", authMiddleware, async (req, res) => {
const characterId = +req.params.characterId; // parameter 가져오기
const character = await Characters.findFirst({
// characterId와 userId가 일치하는 character 가져오기
where: {
characterId,
UserId: req.user.userId,
},
});
if (!character) {
return res
.status(404)
.json({ errorMessage: "아이템을 탈착할 캐릭터가 없습니다." });
}
const mounting = await MountedItems.findFirst({
// characterId가 같은 객체 찾기 (MountedItems 모듈에서)
where: { CharacterId: +characterId },
});
// 아이템 코드가 존재하는지 검사
const { item_code } = req.body; // 장착할 아이템 갸져오기
const item = await Items.findFirst({
// itemCode가 같은 객체 찾기
where: { itemCode: item_code },
});
if (!item) {
// 없으면 에러 메시지
return res
.status(404)
.json({ errorMessage: "탈착할 아이템이 존재하지 않습니다." });
}
// 아이템이 장착되어 있는지 확인
let mountedItems = mounting.items;
const sameItem = mountedItems.find(function (obj) {
return obj.itemCode === +item_code;
});
if (!sameItem) {
// 장착되어있지 않다면 에러 출력
return res.status(400).json({
errorMessage: `캐릭터에게 '${item.name}'이 존재하지 않습니다.`,
});
}
// MountedItems에서 아이템 삭제
mountedItems = mountedItems.filter((val) => {
return val.itemCode !== item_code;
});
let mountingLocation = mounting.mountingLocation;
mountingLocation[item.itemType] = false;
await MountedItems.update({
// db에 업데이트
data: {
items: mountedItems,
mountingLocation,
},
where: {
CharacterId: characterId,
},
});
// 아이템 탈착으로 인한 캐릭터 스탯 변경
const { itemStat } = item;
let { health, power } = character;
health -= itemStat.health ?? 0;
power -= itemStat.power ?? 0;
console.log(health, power);
await Characters.update({
data: {
health,
power,
},
where: {
characterId,
UserId: req.user.userId,
},
});
// inventory에 아이템 추가
let { items } = await Inventories.findFirst({
where: { CharacterId: characterId },
});
items[item_code] = (items[item_code] ?? 0) + 1; // item이 존재하지 않으면 0에서 +1 시킴
console.log(items);
await Inventories.update({
data: {
items,
},
where: { CharacterId: characterId },
});
return res
.status(200)
.json({ completeMessage: "아이템 탈착이 완료되었습니다." });
});
exports.router = router;
auth.middleware.js: 사용자를 인증하는 미들웨어로 개발 중엔 쿠키로 토큰을 받아 빠른 api 테스트를 했으며 배포 전에 headers에서 토큰을 받아오는 로직으로 변경하였다.
const jwt = require("jsonwebtoken");
const { userPrisma } = require("../utils/prisma/index.js");
module.exports = async function (req, res, next) {
try {
const { authorization } = req.headers;
// const { authorization } = req.cookies; // 일단 쿠키로 전달받고 제출전에 headers로 변경
const [tokenType, token] = authorization.split(" ");
if (tokenType !== "Bearer")
throw new Error("토큰 타입이 일치하지 않습니다.");
const decodedToken = jwt.verify(token, process.env.JWT_KEY);
const userId = +decodedToken.userId;
if (!userId) {
throw new Error("로그인 정보가 필요합니다.");
}
const user = await userPrisma.users.findFirst({
where: { userId },
});
if (!user) throw new Error("토큰 사용자가 존재하지 않습니다.");
req.user = user;
next();
} catch (error) {
res.clearCookie("authorization");
switch (error.name) {
case "TokenExpiredError": // 토큰이 만료되었을 때 발생하는 에러
return res.status(401).json({ errorMessage: "토큰이 만료되었습니다." });
case "JsonWebTokenError": // 토큰 검증이 실패했을 때, 발생하는 에러
return res
.status(401)
.json({ errorMessage: "토큰 인증에 실패하였습니다." });
default:
return res.status(401).json({
errorMessage: error.errorMessage ?? "비 정상적인 요청입니다.",
});
}
}
};
// const authMiddleware = require("./middleware/auth.middleware");
error-handling.middleware.js: 에러를 전달받아 사용자에게 출력해주는 에러 처리 미들웨어
module.exports = function (err, req, res, next) {
// 에러를 출력합니다.
console.error(err);
// 클라이언트에게 에러 메시지를 전달합니다.
res.status(500).json({ errorMessage: err });
};
막혔던 부분
데이터베이스를 mongoDB에서 mySQL로 바꾸는 동시에 User db와 Item db를 따로 만드라는 요구 조건이 있었다. 하지만 Prisma는 mySQL에서 다중 데이터베이스 스키마를 지원하지 않는다는 사실을 알고 이를 해결하는데 오랜 시간이 걸렸다.
가장 처음으로 prisma client를 2개 만들어 각각 사용해보려 했지만 다음과 같이 스키마를 push, generate 할 땐 무조건 client라는 이름으로 폴더가 생성되어 user.schema.prisma의 client와 item.schema.prisma의 client의 폴더 이름이 겹쳐 제대로 동작하지 않았다.. client를 따로 만드는 방법을 찾다가 다음과 같은 방법을 발견하여 문제를 해결했다.
↓
위 포스팅에 나왔듯이 해결하는 과정에서 ES6 모듈을 사용하지 않고 commonJS를 사용하여 진행하게 되었다.
아래 GitHub repository의 readme에서 프로젝트 ERD, API 명세서와 API 테스트 결과를 작성해놓았다.
Project Github Link: https://github.com/znfnfns0365/Game-Manager-with-node.js-updated
'내일배움캠프 > Project' 카테고리의 다른 글
[Project] Insomnia에서 팀원들과 협업하기 (0) | 2024.06.05 |
---|---|
[Project] 협업 시 유용한 사이트 및 정보 (0) | 2024.05.31 |
[Project] Node.js와 express를 활용한 게임 아이템 시뮬레이터 서비스 (개인 과제) (0) | 2024.05.20 |
[Project] 영화 검색 사이트 심화 (팀 프로젝트) (0) | 2024.05.12 |
[Project] 영화 검색 사이트 제작 (개인 과제) (0) | 2024.05.02 |