[Project] Klayenglish

George·2022년 8월 29일
0

project

목록 보기
6/6
post-thumbnail

1. 기획 📌


프로젝트 주제

  • 블록체인 기반 영어교육 어플리케이션

프로젝트 정책

  • 1억개의 TUT 토큰을 발행
  • 회원가입 시 10개(변동 가능) 지급
  • 퀴즈 통과 시간에 따라 토큰 차등 지급
  • 게시물 등록 후 추천 수 여부에 따라 토큰 지급
  • 토큰의 지급 방식은 지갑을 통한 실시간 전송이 아닌, DB에서 사용자의 토큰 보유량을 가져와 사용
  • 스왑 시에 DB에서 사용자의 토큰 수만큼 지급하여 수수료를 최소화

프로젝트 기간

  • 2022년 7월 4일 ~ 7월 29일
  • 주차별 일정
    1주차 -> 아이디어 회의 및 기초 UI
    2주차 -> DB연동 및 회원가입
    3주차 -> 퀴즈 구현
    4주차 -> 토큰 기능 구현

개인 목표

스마트컨트랙트 부분을 제외한 프런트, 백엔드 전반적인 부분을 담당하였다.

  • 회원가입, 로그인(유효성, JWT, 접근)
  • 퀴즈 구현 및 강좌 구매(크롤링 데이터 퀴즈화, 강좌별 퀴즈 삽입)
  • MySQL 연동

기술 스택

  • Program Languege

    • JS / TS
  • Front-End

    • Core - React.js
    • State Management - React-Query
    • Styling - styled-component, Emotion
  • Back-End

    • Node.js
    • Express
    • Axios
    • MySQL
    • Cheerio
  • web3

    • Solidity
    • Ethers

Package

  • react
  • @reduxjs/toolkit
  • axios
  • react-router-dom
  • styled-components
  • @web3-react/core
  • @mui/material
  • typescript
  • jsonwebtoken
  • mysql
  • typeorm
  • passport
  • nodemailer

ERD


2. Code review 📌


Crawling

이번 프로젝트의 핵심이 되는 기능인 퀴즈의 데이터를 가져오기 위해 cheerio 라이브러리를 사용했다.


JWT 인증

이번 프로젝트에서 회원가입, 로그인, 퀴즈, 강좌 등 여러부분에서 서버(DB)와 연동해야 했는데, 인증 부분에서 연동 인과관계 파악이 어려워 시간을 많이 할애했다.

로그인 시 해당 아이디, 비밀번호 정보를 서버에서 파악 -> 해당 계정이 존재한다면 유저 강의, 닉네임, 이메일이 담긴 JWT를 localStorage에 저장하는 방식을 택하였다.

src/pages/Singin.tsx 중 일부

  const login = async (event: React.MouseEvent<HTMLButtonElement>) => {
    try {
      fetch("http://localhost:3001/user/login", {
        method: "post",
        headers: {
          "content-type": "application/json",
        },
        body: JSON.stringify({ id: email, pwd: password }),
      }).then((res) => {
        if (res.status >= 200 && res.status <= 204) {
          // msg -> 서버에서 보내오는 데이터
          res
            .json()
            .then((msg) =>
              localStorage.setItem("accessToken", JSON.stringify(msg["data"]))
            );
          console.log("클라이언트 로그인성공");
          dispatch(userActions.setLoggedIn());
        } else if (res.status == 400) {
          navigate("/signin");
          res.json().then((msg) => alert(msg.message));
        }
        console.log(res.status);
        if (res.status >= 400) {
          console.log("해당 입력이 잘못되었습니다.");
        }
      });
    } catch (error) {
      console.log(error);
    }
    // error 메시지 확인 가능 https://krpeppermint100.medium.com/ts-nodejs-express%EC%9D%98-%EC%9A%94%EC%B2%AD-%EC%9D%91%EB%8B%B5-%EC%97%90%EB%9F%AC-%ED%95%B8%EB%93%A4%EB%A7%81-8943ab7bd13b
    navigate("/");
  };

server.js 중 일부

app.post("/user/login", (req, res) => {
  //로그인
  const id = req.body.id;
  const pwd = req.body.pwd;
  const loginInfo = [id, pwd];
  connection.query(
    "SELECT * FROM users where userName=? and password=?",
    loginInfo,
    function (err, rows, fields) {
      if (rows.length > 0) {
        const accessToken = jwt.sign(rows[0]);
        console.log("로그인됨");
        res.status(200).send({
          // client에게 토큰 반환합니다.
          ok: true,
          data: {
            accessToken,
          },
        });
      } else {
        res.status(400).send({
          ok: false,
          message: "해당 유저가 존재하지 않거나, 유효하지 않은 양식입니다.",
        });
        console.log("로그인 fail");
      }
    }
  );
});


토큰이 정상적으로 저장된 것을 확인할 수 있다.


영단어 퀴즈화 및 강좌 구매

영단어 퀴즈화

크롤링한 영단어를 DB에 넣고, 해당 데이터를 퀴즈화 하였다.
(1,1,' 개탄스러운 | 개탄스러운 | 개탄스러운 ','inevitable|comfortable|deplorable','0|0|1',2,'e2k','2022-07-23 16:27:32') 다음과 같은 영어 데이터를 얻을 수 있는데,
해당 문제의 답은 "개탄스러운"이고 답은
inevitable|comfortable|deplorable - 0|0|1
즉 "deplorable"이 정답이 된다.

Test 페이지 접근시 server.js에서 실행되는 코드

app.post("/user/testData", (req, res) => {
  const token = req.headers.authorization.split("Bearer ")[1];
  const result = jwt.verify(token);
  if (result.ok) {
    const { id, pwd, nickname } = result;
    // 유저와 일치하는 데이터를 찾기
    connection.query(
      "SELECT * FROM users where userName=?",
      id,
      function (err, rows, fields) {
        if (rows[0].taken_lectures == null) {
          res.status(201).send({ message: "보유한 강좌가 없습니다." });
        } else {
          const userLec = rows[0].taken_lectures.split("|");
          // [ '1', '2' ]
          let cunQuery = "lec_id in (?) ";
          for (let i = 1; i < userLec.length; i++) {
            if (userLec === 1) {
              cunQuery = "lec_id in (?) ";
            } else {
              cunQuery += "OR lec_id in (?)";
            }
          }
          connection.query(
            "select * from lecture where " + cunQuery + "",
            userLec,
            function (err, rows) {
              if (err) {
                console.log(err);
              } else {
                const lecData = rows;
                connection.query(
                  "SELECT lec_name,pass_state FROM lecturestate WHERE userName = ?",
                  id,
                  function (err, rows) {
                    if (err) {
                      console.log(err);
                    } else {
                      console.log(`${id}님이 보유 강좌 데이터를 불러왔습니다.`);
                      res.status(200).send({ lec: lecData, lecPass: rows });
                    }
                  }
                );
              }
            }
          );
        }
      }
    );
  } else {
    // 검증에 실패하거나 토큰이 만료되었다면 클라이언트에게 메세지를 담아서 응답합니다.
    res.status(401).send({
      ok: false,
      message: result.message, // jwt가 만료되었다면 메세지는 'jwt expired'입니다.
    });
  }
});

유저가 가지고 있던 토큰을 확인하여 해당 유저가 가진 강좌를 DB에서 찾고
해당 강좌를 Test 페이지에 불러오는 과정이다.
useEffect로 해당 페이지 접속시 데이터를 불러와 redux store에 저장한다.

src/pages/ChoserTest.tsx 중 일부

 useEffect(() => {
    if (!isLoggedIn) {
      navigate("/signin", { replace: true });
      dispatch(modalActions.openNeedLoginModalOpen());
    } else {
      const testData = async () => {
        const token: any = localStorage.getItem("accessToken");
        const parseToken: any = JSON.parse(token);
        try {
          fetch("http://localhost:3001/user/testData", {
            method: "post",
            headers: {
              // 강좌를 구매한 유저 정보를 식별하기 위한 토큰 전송(DB에서 해당 유저에게 구매한 강좌를 저장시키기 위함)
              authorization: `Bearer ${parseToken.accessToken}`,
            },
            // server로 클릭한 강좌 정보 전송
            body: JSON.stringify({}),
          }).then((res) =>
            res.json().then((result) => {
              // 강좌가 없다면?
              if (res.status == 201) {
                return alert(result.message);
              }
              // DB에서 가져온 데이터를 slice에 넣어주는데, 중복저장이 안되게 for문을 먼저 진행.
              // 이건 lecturestate 데이터 값을 불러옴
              // 추후 퍼센테이지 채울 때 필요함
              const passList = result.lecPass.map((el: any) => el);
              // 강좌에 따른 통과진행률 데이터를 passSlice에 저장
              // 퀴즈를 통과하고 다시 들어올 때 새로고침을 안하면 통과율 저장이 안됨, slice내 추가 action을 만들어야 할 것 같다 - 규현
              passList.map((el: any) => {
                for (let i = 0; i < passData.length; i++) {
                  if (el.lec_name == passData[i].lecId) {
                    console.log("이미 불러온 데이터 입니다.");
                    return;
                  }
                }
                dispatch(
                  setPassData({
                    lecId: el.lec_name,
                    passed: el.pass_state,
                  })
                );
              });
              // lecture의 데이터를 넣는 lectureSlice에 넣는 과정
              result.lec.map((el: any) => {
                for (let i = 0; i < lecture.length; i++) {
                  if (el.lec_id == lecture[i].id) {
                    console.log("이미 불러온 데이터 입니다.");
                    return;
                  }
                }
                dispatch(
                  lecData({
                    id: el.lec_id,
                    image: el.lec_image_path,
                    level: el.lec_level,
                    name: el.lec_name,
                    source: el.lec_source,
                  })
                );
              });
            })
          );
        } catch (error) {
          console.error(error);
        }
      };
      testData();
    }
  }, [isLoggedIn, navigate]);

이 과정을 진행하면 추후 퀴즈 데이터를 불러올 때 redux store에 저장되어 있으므로 새로고침 하지 않는다면 매번 DB에게 받아올 필요없이 프런트에서 해결할 수 있다.

src/store/qzSlice.ts
아래 코드는 store의 퀴즈 슬라이스이다.

import { createSlice, PayloadAction } from "@reduxjs/toolkit";

type qzState = {
  answer: string;
  correct: string;
  lec_id: number;
  question: string;
  qz_category: string;
  qz_id: number;
  qz_num: number;
};

const initialState: qzState[] = [];

export const qzSlice = createSlice({
  name: "lecture",
  initialState,
  reducers: {
    qzData: (
      state = initialState,
      action: PayloadAction<{
        answer: string;
        correct: string;
        lec_id: number;
        question: string;
        qz_category: string;
        qz_id: number;
        qz_num: number;
      }>
    ) => {
      state.push(action.payload);
    },
    resetData: (state) => {
      state.splice(0);
    },
  },
});

export const { qzData } = qzSlice.actions;

export const qzActions = { ...qzSlice.actions };

export default qzSlice;

강좌 구매

우선 강좌 구매 페이지에 판매 중인 강좌를 표시해주기 위해 useEffect를 사용했다.

src/pages/Coures.tsx 중 일부

  const [cards, setCards] = useState([]);
  useEffect(() => {
    const cardData = async () => {
      try {
        fetch("http://localhost:3001/selectCard", {
          method: "post",
          headers: {
            "content-type": "application/json",
          },
          body: JSON.stringify({}),
        }).then((res) =>
          res.json().then((result) => {
            setCards(result);
            console.log(result);
          })
        );
      } catch (error) {
        console.error(error);
      }
    };
    cardData();
  }, []);

이후 map 메서드를 활용하여 CardItem 컴포넌트에 서로 다른 props를 넣어준다.

/CardItem.tsx

  // 구매함수
  const handleBuyClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    if (!isLoggedIn) {
      navigate("/signin", { replace: true });
      dispatch(modalActions.openNeedLoginModalOpen());
    } else {
      const cardData = async () => {
        const token: any = localStorage.getItem("accessToken");
        const parseToken: any = JSON.parse(token);
        try {
          fetch("http://localhost:3001/user/payment", {
            method: "post",
            headers: {
              "content-type": "application/json",
              // 강좌를 구매한 유저 정보를 식별하기 위한 토큰 전송(DB에서 해당 유저에게 구매한 강좌를 저장시키기 위함)
              authorization: `Bearer ${parseToken.accessToken}`,
            },
            // server로 클릭한 강좌 정보 전송
            body: JSON.stringify({ lecInfo: props }),
          }).then((res) => {
            res.json().then((msg) => {
              // 이미 구매한 강좌 유효성 검사
              if (!msg.ok) {
                alert("이미 구매하신 강좌입니다.");
                setGet("complete");
              } else {
                alert("구매완료!");
                setGet("complete");
              }
            });
          });
        } catch (error) {
          console.error(error);
        }
      };
      cardData();
    }
  };

해당 컴포넌트에는 각 prop의 정보가 담긴 개별적인 강좌 카드가 있고,
특정 유저가 강좌를 구매한다면, 유저 테이블 "taken_lectures" varchar(255) 칼럼에서 해당 강좌의 id를 추가한다.

구매 순서대로 강좌가 유저 lectures 칼럼에 추가되는 것을 확인할 수 있다.
server.js 중 강좌 구매 부분

// 강좌구매 시 작동(유효성 검사 완)
app.post("/user/payment", (req, res) => {
  const token = req.headers.authorization.split("Bearer ")[1];
  const result = jwt.verify(token);
  const info = req.body.lecInfo;
  console.log(info);
  const { lec_id, lec_price } = info;
  if (result.ok) {
    const { id, pwd, nickname } = result;
    // 유저와 일치하는 데이터를 찾기
    connection.query(
      "SELECT * FROM users where userName=?",
      id,
      function (err, rows, fields) {
        // 강의를 처음 구매할 시
        if (rows[0].taken_lectures == null) {
          connection.query(
            "UPDATE users SET taken_lectures = (?) WHERE userName = ?",
            [lec_id, id],
            function (err, rows) {
              connection.query(
                "INSERT INTO lecturestate(lec_name,userName,pass_state) values (?,?,?)",
                [lec_id, id, "none"]
              );
              res.status(200).send({ ok: true, message: lec_id });
              console.log(
                `${id}님이 lec_id : ${lec_id} 강좌를 구매하였습니다. lecturestate에 해당 정보를 저장합니다.`
              );
            }
          );
        } else {
          // 해당 유저의 강의에 현재 구매한 강의 아이디를 넣기
          const preLec = rows[0].taken_lectures;
          // 담고있는 lec_id를 새로 들어온 강의 id와 비교할 수 있게 숫자를 담은 배열로 변경하는 과정 -규현
          const aryPreLec = preLec.split("|");
          const numLec = aryPreLec.join("");
          const strLec = String(numLec);
          // mapfn : 배열 내 모든 요소를 숫자로 변경 https://hianna.tistory.com/707
          const mapfn = (arg) => Number(arg);
          const newLec = Array.from(strLec, mapfn);
          // 처음 구매하고 바로 다시 처음 강좌를 재구매 할 때
          if (preLec == lec_id) {
            console.log("이미 구매한 강좌입니다.");
            res.status(400).send({ ok: false, message: lec_id });
          }
          // 이후 중복 구매시
          else if (newLec.includes(lec_id)) {
            console.log("이미 구매한 강좌입니다.");
            res.status(400).send({ ok: false, message: lec_id });
          } else {
            // 새로운 값 추가할 때 "|"
            const ary = [preLec + "|" + lec_id];
            connection.query(
              "UPDATE users SET taken_lectures = (?) WHERE userName = ?",
              [[ary], id],
              function (err, rows) {
                connection.query(
                  "INSERT INTO lecturestate(lec_name,userName,pass_state) values (?,?,?)",
                  [lec_id, id, "none"]
                );
                res.status(200).send({ ok: true, message: lec_id });
                console.log(
                  `${id}님이 lec_id : ${lec_id} 강좌를 구매하였습니다. lecturestate에 해당 정보를 저장합니다.`
                );
              }
            );
          }
        }
      }
    );
  } else {
    // 검증에 실패하거나 토큰이 만료되었다면 클라이언트에게 메세지를 담아서 응답합니다.
    res.status(401).send({
      ok: false,
      message: result.message, // jwt가 만료되었다면 메세지는 'jwt expired'입니다.
    });
  }
});

그 외

AlertModal.tsx

interface Props extends React.HTMLAttributes<HTMLDivElement> {
  message?: string;
}

const AlertModal: React.FC<Props> = ({ message, ...props }) => {
  return (
    <Base {...props}>
      <span className="text">{message}</span>
      <div className="loader4"></div>
    </Base>
  );
};

export default AlertModal;

extends React.HTMLAttributes<HTMLDivElement> 처럼 확장을 해줘야 className 등 여러 옵션을 추가적용할 수 있다.
...props 를 넣어야 다른 곳에서 className 적용가능


3. 구현 화면 📌


Main

지갑연동, 로그인은 오른쪽 / 테스트, 강좌, 커뮤니티를 왼쪽로고 옆에 뒀다.


Sign in, Sign up

저번에 진행한 프로젝트를 참고하여 유효성 검사를 통과한 회원가입 예시 영상이다.


Course

강좌를 구매하지 않은채로 Test 페이지로 들어가면 alret가 작동된다.(비로그인시 마찬가지)
강좌 hover 시 강좌카드 스타일 변경, click 시 flip

각 강좌마다 토큰 가격이 명시. 추후 토큰의 지급량과 더불어 시세에 맞게 변동 할 수 있음

해당 계정이 보유한 강좌와 비교하여 중복되는 강좌일 때 구매할 수 없도록 표시


Quiz

퀴즈 문제 별 선택에 따른 합/불의 결과는 store에 저장되어 결과창에서 맞은 개수 만큼 %되어 나온다. 이 %가 80% 이상이면, 그 다음 퀴즈를 풀수 있게 된다.

추후 특정 시간, 정답률에 따른 토큰 차등보상 지급예정

일차별 통과율이 초기 test 페이지 강좌카드에 표시된다.

이전 퀴즈를 통과(80% 이상)해야 다음 일차 퀴즈를 풀 수 있음
통과하지 못한 상태로 다음 일차를 클릭한다면 해당 화면과 같이 알림이 뜬다.

해당 일차 퀴즈 정답, 오답 표기 및 정답률이 표시됨. 추후 오답노트 기능 구현 예정


4. 후기 📌


Github
Notion

  • 처음으로 3명의 팀원과 함께 4주간 만들어본 프로젝트였다.
  • 아이디어 회의부터 배포까지 a-Z까지 직접 해보았는데 아무것도 없는 상태에서 이러한 결과까지 나왔다는 것이 무척이나 신기하다.
  • 또한 팀원들이 없었다면 인증, 크롤링에서 시간을 더 할애했을 것이다.
  • typescript, redux에 대해서 능숙하지는 않았지만 더 자세하게 알 수 있는 계기가 되었다.
  • 이번 배포는 팀원의 도움이 컸지만, 다음 미니 프로젝트에선 직접 배포까지 해보고 싶다.

5. 참고자료 📌


0개의 댓글