Browse Source

init

master
songwritersg 12 months ago
commit
2d77ed7897
  1. 21
      back-end/app/config/config.development.js
  2. 21
      back-end/app/config/config.production.js
  3. 95
      back-end/app/core/core.js
  4. 13
      back-end/app/core/database.js
  5. 54
      back-end/app/core/global.js
  6. 5
      back-end/app/index.js
  7. 93
      back-end/app/libraries/attach.library.js
  8. 334
      back-end/app/modules/board/board.controller.js
  9. 144
      back-end/app/modules/board/board.model.js
  10. 15
      back-end/app/modules/board/board.routes.js
  11. 263
      back-end/app/modules/users/users.controller.js
  12. 105
      back-end/app/modules/users/users.model.js
  13. 14
      back-end/app/modules/users/users.routes.js
  14. 1667
      back-end/package-lock.json
  15. 29
      back-end/package.json
  16. 3
      front-end/.browserslistrc
  17. 17
      front-end/.eslintrc.js
  18. 24
      front-end/.gitignore
  19. 24
      front-end/README.md
  20. 5
      front-end/babel.config.js
  21. 19
      front-end/jsconfig.json
  22. 11860
      front-end/package-lock.json
  23. 35
      front-end/package.json
  24. BIN
      front-end/public/favicon.ico
  25. 17
      front-end/public/index.html
  26. 32
      front-end/src/App.vue
  27. BIN
      front-end/src/assets/logo.png
  28. 60
      front-end/src/components/HelloWorld.vue
  29. 28
      front-end/src/components/MyFormInputWrap.vue
  30. 41
      front-end/src/components/MyFormLabel.vue
  31. 12
      front-end/src/components/MyFormRow.vue
  32. 29
      front-end/src/components/MyInput.vue
  33. 44
      front-end/src/components/MyTextInput.vue
  34. 0
      front-end/src/components/WysiwygEditor.vue
  35. 34
      front-end/src/main.js
  36. 51
      front-end/src/mixins.js
  37. 34
      front-end/src/models/boardModel.js
  38. 82
      front-end/src/models/userModel.js
  39. 137
      front-end/src/plugins/axios.js
  40. 51
      front-end/src/plugins/formatter.js
  41. 1
      front-end/src/router/agreement.routes.js
  42. 7
      front-end/src/router/authorize.routes.js
  43. 14
      front-end/src/router/board.routes.js
  44. 40
      front-end/src/router/index.js
  45. 6
      front-end/src/router/my.routes.js
  46. 34
      front-end/src/store/auth/index.js
  47. 21
      front-end/src/store/index.js
  48. 5
      front-end/src/views/AboutView.vue
  49. 65
      front-end/src/views/Authorize/SignIn.vue
  50. 308
      front-end/src/views/Authorize/SignUp.vue
  51. 97
      front-end/src/views/Board/BoardPostList.vue
  52. 178
      front-end/src/views/Board/BoardPostView.vue
  53. 268
      front-end/src/views/Board/BoardPostWrite.vue
  54. 67
      front-end/src/views/Board/BoardView.vue
  55. 3
      front-end/src/views/Board/Skins/PostGallery.vue
  56. 70
      front-end/src/views/Board/Skins/PostSimpleList.vue
  57. 3
      front-end/src/views/Board/Skins/PostWebzine.vue
  58. 9
      front-end/src/views/Errors/Error404.vue
  59. 30
      front-end/src/views/Errors/Test.vue
  60. 18
      front-end/src/views/HomeView.vue
  61. 0
      front-end/src/views/My/MyPage.vue
  62. 4
      front-end/vue.config.js

21
back-end/app/config/config.development.js

@ -0,0 +1,21 @@
module.exports = {
appPort: 3000, // 앱 구동 포트
secretKey: 'wheeparam', // 암호화를 위해 특정한 키 입력
// 데이타베이스 관련 설정
database: {
host: '',
username: '',
password: '',
port: 3306,
database: ''
},
// CORS관련 설정
cors: {
origin: true,
credentials: true
},
jwt: {
accessTokenExpire: '1m', // accessToken 의 만료시간 (1분)
refreshTokenExpire: '14d', // refreshToken 의 만료시간 (14일)
}
}

21
back-end/app/config/config.production.js

@ -0,0 +1,21 @@
module.exports = {
appPort: 3000, // 앱 구동 포트
secretKey: 'wheeparam', // 암호화를 위해 특정한 키 입력
// 데이타베이스 관련 설정
database: {
host: '',
username: '',
password: '',
port: 3306,
database: ''
},
// CORS관련 설정
cors: {
origin: true,
credentials: true
},
jwt: {
accessTokenExpire: '1m', // accessToken 의 만료시간 (1분)
refreshTokenExpire: '14d', // refreshToken 의 만료시간 (14일)
}
}

95
back-end/app/core/core.js

@ -0,0 +1,95 @@
const App = {
express: null,
isDev: false,
config: {}
}
// 주요 의존성패키지
const express = require('express')
const bodyParser = require('body-parser')
const cors = require('cors')
const fs = require('fs')
const cookieParser = require('cookie-parser')
process.env.TZ = 'Asia/Seoul';
App.express = express()
// 방금 작성한 global.js 파일을 로드합니다.
require('./global')
// CookieParser, bodyParser 를 로드합니다.
// 여기서 appConfig 변수는 global.js 파일에서 각 환경에 맞는 config파일을 로드하여 할당된 변수입니다.
App.express.use(cookieParser(appConfig.secretKey))
App.express.use(bodyParser.json())
App.express.use(bodyParser.urlencoded({extended: true}))
App.express.use(cors(appConfig.cors)) // CORS 설정
/**
* Helper에 등록된 helper들 자동으로 불러오기
*/
// helpers 폴더의 파일 목록을 가져옵니다.
let fileList = fs.readdirSync(root + '/helpers');
// 파일들을 전부 로드합니다.
fileList.forEach(async (fileName) => {
require(root + '/helpers/' + fileName);
});
/**
* 전역 Middleware
* ------------------------------------------------------------------------------------
* 사용자 로그인 여부 체크
*/
const userController = loadModule('users','controller');
App.express.use(userController.loginUserCheck);
/**
* 모듈에 등록된 Router 자동으로 불러오기
*/
// 라우터 라이브러리를 로드하고 router 객체에 할당 해줍니다.
const router = require('express').Router();
// modules 폴더에 등록된 디렉토리 목록을 불러옵니다.
let dirList = fs.readdirSync(modulePath)
dirList.forEach((dir) => {
// 디렉토리가 맞을경우
if(fs.lstatSync(modulePath + '/' + dir).isDirectory()) {
// 라우팅 설정파일이 존재할 경우
const routePath = `${modulePath}/${dir}/${dir}.routes.js`;
const matchPath = `/${dir}`
// 파일이 존재한다면 router.use로 해당 라우트를 등록해준다.
if(fs.existsSync( routePath )) {
router.use(matchPath, require(routePath))
}
}
});
// App.express 객체에 위에 불러온 router 설정을 사용 설정해줍니다.
App.express.use(router);
/**
* 업로드 관련 Router 추가
*/
const attachLibrary = require(root + '/libraries/attach.library')
const path = require("path");
App.express.use(attachLibrary);
// REST API URL 중 /attaches 로 접근하는 리소스는 모두 그대로 리소스를 반환해줍니다.
// 정적인 파일 (이미지, 첨부파일등)
App.express.use('/attaches', express.static(path.join(root, 'data', 'uploads')));
/**
* 어플리케이션 실행
* ------------------------------------------------------------------------------------
* @param port 실행 포트
*/
App.start = () => {
// Listen 시작
App.express.listen(appConfig.appPort, '0.0.0.0', () => {
console.log(`[${isDev ? '개발 모드':'릴리즈 모드'}] 서버가 작동되었습니다 : port ${appConfig.appPort}`);
})
}
module.exports = App

13
back-end/app/core/database.js

@ -0,0 +1,13 @@
const knex = require('knex')({
client:'mysql2',
connection: {
host: appConfig.database.host,
user: appConfig.database.username,
password: appConfig.database.password,
port: appConfig.database.port,
database: appConfig.database.database,
},
debug : false
})
module.exports = knex;

54
back-end/app/core/global.js

@ -0,0 +1,54 @@
"use strict";
const path = require("path");
const fs = require("fs");
/**
* App 개발환경 정의
* ------------------------------------------------------------------------------------
* 정의되지 않은 경우 자동으로 false 처리
* 어플리케이션을 실행할때 입력한 추가 argument를 통해 dev와 prod 상태를 구분합니다.
* package.json 에서 서버 구동 npm script를 작성할때 뒤에 붙인 --dev 옵션입니다.
*/
global.isDev = ( process.argv.length > 2 && process.argv[2] === '--dev' )
/**
* App Document Root 지정
* ------------------------------------------------------------------------------------
* back-end 루트를 절대경로 구해와서 root 라는 글로벌 변수에 할당합니다.
* 우리가 주로 로드해서 사용할 모듈폴더 (modules) 역시 글로벌 변수에 할당합니다.
*/
global.root = path.resolve(__dirname + '/../../app');
global.modulePath = root + '/modules'
/**
* 사용 환경에 따른 APP 개발 환경설정 불러오기
* ------------------------------------------------------------------------------------
* 위의 개발환경에 따라 config폴더에서 각각 다른 파일을 불러오도록 합니다.
* config.development.js , config.production.js 파일 두파일로 분리됩니다.
*/
global.appConfig = require(path.resolve(root + '/config/config.' + (isDev?'development':'production') + '.js'));
/**
* 모듈 불러오기
* @param moduleName 모듈 이름
* @param moduleType 모듈 타입
* @returns {*}
*/
global.loadModule = ( moduleName, moduleType = 'controller') => {
const modulePath = `${root}/modules/${moduleName}/${moduleName}.${moduleType}.js`
if (!fs.existsSync(modulePath)) {
throw Error('로드하려는 모듈이 존재하지 않습니다')
}
const t =require(modulePath);
return t;
}
/**
* 데이타베이스 객체 불러오기
* @returns {*}
*/
global.database = () => {
return require(`${root}/core/database.js`)
}

5
back-end/app/index.js

@ -0,0 +1,5 @@
// core.js 파일을 로드한다.
const app = require('./core/core')
// 웹서버를 가동한다.
app.start();

93
back-end/app/libraries/attach.library.js

@ -0,0 +1,93 @@
const express = require('express')
const path = require("path");
const md5 = require("md5");
const multer = require("multer");
const router = require('express').Router();
const randomstring = require('randomstring');
const fs = require("fs");
/**
* 사용자 파일 업로드
*/
router.post('/attaches', async (req, res) => {
// 업로드 허용 확장자
const allowExt = [".csv", ".psd", ".pdf", ".ai", ".eps", ".ps", ".smi", ".xls", ".ppt", ".pptx",".gz", ".gzip", ".tar", ".tgz", ".zip", ".rar", ".bmp", ".gif", ".jpg", ".jpe",".jpeg", ".png", ".tiff", ".tif", ".txt", ".text", ".rtl", ".xml", ".xsl",".docx", ".doc", ".dot", ".dotx", ".xlsx", ".word", ".srt",".webp"];
// 이미지 파일 확장자
const imageExt = [".gif",".jpg",".jpe",".jpeg",".png",".tiff",".tif",".webp"]
// 업로드 경로를 계산합니다.
// 업로드 경로는 app/data/uploads/년도/월 이 됩니다
const yearMonth = path.posix.join((new Date().getFullYear()).toString() , (new Date().getMonth() + 1).toString());
const directory = path.join('data','uploads',yearMonth); // 루트 디렉토리를 기준으로 업로드 경로
const uploadPath = path.join(root, 'data', 'uploads', yearMonth); // 루트 디렉토리를 포함한 업로드 경로
// multer 미들웨어 설정
const upload = multer({
dest: uploadPath,
}).array('userfile'); // 파일 필드의 이름을 지정
// 디렉토리가 존재하지 않는다면, 디렉토리를 생성합니다.
try {
if (!fs.existsSync(uploadPath)) {
// 디렉토리가 없는 경우 생성
fs.mkdirSync(uploadPath, { recursive: true });
}
} catch (err) {
console.error('디렉토리 생성 중 에러 발생:', err);
return res.status(500).json({ error: 'Failed to create directory' });
}
const resultArray = [];
// multer 미들웨어 실행
upload(req, res, async (err) => {
if (err) {
console.error('파일 업로드 중 에러 발생:', err);
return res.status(500).json({ error: '파일 업로드 실패' });
}
req.files.map(async (file) => {
const ext = path.extname(file.originalname).toLowerCase(); // 파일의 원본이름에서 확장자를 구합니다.
const originalName = file.originalname; // 파일의 원본명은 따로 저장해둡니다.
// 업로드 허용 확장자인지 체크합니다.
if( allowExt.indexOf(ext) < 0) {
return res.status(400).json({error: '업로드가 허용되지 않는 확장자를 가진 파일이 포함되어 있습니다 : ' + ext})
}
let fileName = md5(`${Date.now()}_${originalName}`) + randomstring.generate(5) + ext; // 파일이름을 랜덤하게 변경합니다. 한글이나 유니코드등 사용할수 없는 문자를 처리하기 위함입니다.
let filePath = path.join(uploadPath, fileName); // 파일이 업로드될 경로 +
// 혹시 동일한 파일명이 존재한다면 파일명이 겹치지 않을떄까지 파일명을 계속 변경해 봅니다.
while (fs.existsSync(filePath)) {
fileName = md5(`${Date.now()}_${originalName}`) + randomstring.generate(5) + ext;
filePath = path.join(uploadPath, fileName);
}
fs.renameSync( file.path, filePath )
// 응답결과에 추가해줍니다.
resultArray.push({
file_url: path.join(path.sep, 'attaches', yearMonth, fileName),
file_path: path.join(directory , fileName),
original_name: originalName,
isImage: imageExt.indexOf(ext) >= 0,
extension: ext,
mime: file.mimetype,
file_size: file.size
});
})
return res.json(resultArray);
});
});
/**
* 객체 내보내기
*/
module.exports = router;

334
back-end/app/modules/board/board.controller.js

@ -0,0 +1,334 @@
const repl = require("repl");
const boardController = {};
/**
* 게시판 정보를 가져옵니다.
*/
boardController.getBoardInfo = async(req, res) => {
const boardKey = req.params?.boardKey ?? ''
// 게시판 키가 올바르게 넘어왔는지 확인합니다.
if(! boardKey) {
return res.status(400).json({error:'존재 하지 않거나 삭제된 게시판입니다.'});
}
// 게시판 모델 불러오기
const boardModel = loadModule('board', 'model')
// 게시판 정보 가져오기
const boardInfo = await boardModel.getBoard(boardKey)
if(!boardInfo || boardInfo === {} )
{
return res.status(400).json({error:'존재 하지 않거나 삭제된 게시판입니다.'})
}
return res.json({result: boardInfo})
}
boardController.getPost = async(req, res) => {
const postId = req.params?.postId
const boardModel = loadModule('board', 'model')
const result = await boardModel.getPostOne(postId)
if(result) {
// 문자열 형태로 되어있는 attach_list는 JSON형태로 변환해준다.
result.attach_list = JSON.parse(result.attach_list);
result.is_notice = result.is_notice === 'Y'
}
return res.json({result})
}
boardController.getPostList = async(req, res) => {
const params = {}
params.key = req.params?.boardKey ?? ''
params.page = req.query?.page ?? 1
params.page_rows = req.query?.page_rows ?? 10
params.searchColumn = req.query?.searchColumn ?? ''
params.searchQuery = req.query?.searchQuery ?? ''
// 게시판 모델 불러오기
const boardModel = loadModule('board', 'model')
// 먼저 공지글 부터 불러온다
params.isNotice = true;
const noticeList = await boardModel.getPost(params);
// 일반 게시글을 불러온다.
params.isNotice = false;
const list = await boardModel.getPost(params);
// 반환할 객체를 반든다.
const result = {
result: [...noticeList.result, ...list.result], // 공지사항 목록과 일반게시글 목록을 합친다.
totalCount: list.totalCount
}
return res.json(result)
}
/**
* 게시글 작성 / 수정 / 답글 처리
*/
boardController.writeBoardPost = async(req, res) => {
const boardKey = req.params?.boardKey ?? ""
let postId = req.params?.postId ?? 0
postId = postId * 1;
const postParentId = req.body?.parent_id * 1 ?? 0
// 넘어온 데이타에 따라 수정인지 신규인지 답글인지 미리 정의해둔다
const isEdit = postId > 0
const isReply = postId === 0 && postParentId > 0
// 저장할 데이타를 먼저 정리한다.
const updateData = {};
updateData.title = req.body?.title ?? ''
updateData.content = req.body?.content ?? ''
updateData.category = req.body?.category ?? ''
updateData.author_name = req.body?.author_name ?? ''
updateData.author_pass = req.body?.author_pass ?? ''
updateData.is_notice = req.body?.is_notice === true ? 'Y' : 'N'
updateData.updated_at = new Date()
updateData.updated_user = req.loginUser.id
updateData.updated_ip = req.loginUser.ip
// 첨부파일 목록
const attach_list = req.body?.attach_list ?? []
updateData.attach_list = JSON.stringify(attach_list);
// 게시판 모델 불러오기
const boardModel = loadModule('board','model');
// 답글이거나, 수정일 경우 원본 글을 가져와야 한다.
let refData = null;
if(isEdit) {
refData = await boardModel.getPostOne(postId);
if(! refData) {
return res.status(400).json({error:'수정하려는 원본글이 존재하지 않거나 이미 삭제되었습니다.'})
}
}
else if (isReply) {
refData = await boardModel.getPostOne(postParentId);
if(! refData) {
return res.status(400).json({error:'답글을 작성하려는 원본글이 존재하지 않거나 이미 삭제되었습니다.'})
}
}
// 로그인된 사용자의 경우, 비밀번호와 작성자이름은 비워둔다.
if(req.loginUser.id > 0) {
updateData.author_name = ''
updateData.author_pass = ''
}
// 비로그인 사용자의 경우, 이름과 비밀번호를 작성하였는지 체크한다.
else {
if(updateData.author_name.length === 0) {
return res.status(400).json({error:'닉네임을 입력하셔야 합니다.'});
}
if(updateData.author_pass.length === 0) {
return res.status(400).json({error:'비밀번호를 입력하셔야 합니다.'});
}
// 입력받은 비밀번호를 암호화 한다.
updateData.author_pass = require('sha256')(require('md5')(appConfig.secretKey + updateData.author_pass))
// 수정글인 경우 입력한 비밀번호와 기존 글의 비밀번호가 동일한지 확인한다.
if(isEdit) {
if (updateData.author_pass !== refData?.author_pass) {
return res.status(400).json({error:'수정하려는 글의 비밀번호가 맞지않습니다.'})
}
}
}
// 신규등록일 경우 필수 정보를 추가해준다.
if(! isEdit) {
updateData.board_key = boardKey;
updateData.type = req.body?.type ?? 'POST' // DEFAULT 로 'POST' 입력, 댓글일경우 명시해줘야함
updateData.created_at = updateData.updated_at
updateData.created_user = updateData.updated_user
updateData.created_ip = updateData.updated_ip
if(isReply) {
updateData.parent_id = postParentId
}
}
// 첨부된 파일중 이미지 파일이 있다면 썸네일로 지정함
updateData.thumbnail = ''
// 첨부파일 배열을 돌면서 검사
for(let i=0; i<attach_list.length; i++) {
if(attach_list[i].isImage) {
// 해당 첨부파일이 이미지이면 첨부파일로 값을 넣고, for문 break;
updateData.thumbnail = attach_list[i].file_url;
break;
}
}
updateData.attach_count = attach_list.length;
// 데이타베이스 처리 객체
const db = database()
// 멀티뎁스 게시판을 위한 처리
// 신규작성일때만 처리한다.
if(! isEdit)
{
// 부모게시글이 없다면?
if(! isReply) {
updateData.reply = ''
// num은 게시글의 가장 큰 num 값을 가져와 1을 더한다.
updateData.num = 1
await db('tbl_board_posts').max('num', {as: 'max'})
.then((rows) => {
if(rows && rows[0]) {
updateData.num = (rows[0]?.max ?? 0) + 1
}
})
}
else {
// num은 부모의 num을 그대로 따른다.
updateData.num = refData.num
// reply를 계산하자. reply의 컬럼 길이가 10이므로 최대 10단계까지 답변가능
if(refData.reply.length >= 10) {
return res.status(400).json({error:'더 이상 답변할 수 없습니다. 답변은 10단계 까지만 가능합니다.'})
}
const replyLen = refData.reply.length + 1;
const begin_reply_char = 'A';
const end_reply_char = 'Z';
let replyChar = begin_reply_char;
let query = `SELECT MAX(SUBSTRING(reply, ${replyLen}, 1)) AS reply FROM tbl_board_posts WHERE board_key = ? AND num = ? AND SUBSTRING(reply, ${replyLen}, 1) <> '' `
let bindList = [boardKey, refData.num]
if(replyLen >0) {
query += " AND reply LIKE ? "
bindList.push( refData.reply + '%' );
}
await db.raw(query, bindList)
.then(rows => {
if(rows && rows[0]) {
if(rows[0].reply === end_reply_char ) {
return res.status(500).json({error: '더 이상 답변할 수 없습니다. 답변은 26개까지만 가능합니다.'})
}
else if (rows[0].reply){
replyChar = String.fromCharCode(rows[0].reply.charCodeAt(0) + 1);
}
}
})
}
}
// 실제 DB 입력처리
try {
if(isEdit) {
await db('tbl_board_posts').where('id', postId).update(updateData);
}
else {
await db('tbl_board_posts').insert(updateData).then(id => {
postId = id;
});
}
}
catch {
return res.status(500).json({error:'DB 입력도중 오류가 발생하였습니다.'});
}
return res.status(200).json({result: {id: postId}})
}
/**
* 게시판 조회수 처리하기
*/
boardController.increasePostHit = async(req, res) => {
// 패러미터에서 값을 받습니다.
const boardKey = req.params?.boardKey ?? ''
const postId = (req.params?.postId?? 0) * 1
console.log(boardKey, postId);
if(! boardKey || postId < 0) {
// 조회수를 올리는 작업의 경우 실패하여도 사용자에겐 보이지 않도록 silent 하게 처리해야하므로 오류일 경우에도 STATUS 200처리.
return res.json({})
}
try {
const db = database()
await db.raw("UPDATE tbl_board_posts SET `hit` = `hit` + 1 WHERE id = ?", [postId]);
}
catch {}
return res.json({})
}
/**
* 게시글 삭제하기
*/
boardController.deletePost = async(req, res) => {
// 패러미터에서 값을 받습니다.
const boardKey = req.params?.boardKey ?? ''
const postId = (req.params?.postId?? 0) * 1
// 먼저 원본 게시글 데이타를 가져옵니다.
const boardModel = loadModule('board', 'model');
const original = boardModel.getPostOne(postId);
// 원본글이 없거나 이미 삭제된 글의 경우 처리
if(! original || original.status === 'N' ) {
return res.status(400).json({error: '존재하지 않거나 이미 삭제된 글입니다.'});
}
// 삭제 권한을 체크합니다.
if(this.loginUser.auth < 10 ) {
// 관리자 권한이 아닌경우
if( original.created_user === 0 && this.loginUser.id === 0 )
{
// 작성자도 비회원, 현재 사용자도 비회원일경우 비밀번호 체크
const password = req.body?.password ?? ''
const encryptedPassword = require('sha256')(require('md5')(appConfig.secretKey + password))
// 비밀번호가 다를경우
if(encryptedPassword !== original.author_pass) {
return res.status(400).json({error :'해당 글을 삭제할 권한이 없습니다.'});
}
}
else if (original.created_user !== this.loginUser.id)
{
// 작성자와 현재 로그인 회원이 다른경우
return res.status(400).json({error :'해당 글을 삭제할 권한이 없습니다.'});
}
}
// STATUS를 N 처리해준다.
try {
const db = database()
await db(boardModel.postTableName)
.where('id', postId)
.update({
status: 'N',
updated_at: new Date(),
updated_user: this.loginUser.id,
updated_ip : this.loginUser.ip``
})
}
catch {
return res.status(500).json({error:'DB 에러'});
}
return res.json({})
}
module.exports = boardController

144
back-end/app/modules/board/board.model.js

@ -0,0 +1,144 @@
const boardModel = {};
boardModel.tableName = 'tbl_board'
boardModel.postTableName = 'tbl_board_posts'
/**
* 게시판 키를 이용하여 게시판 정보를 가져옵니다.
*/
boardModel.getBoard = async ( key )=>{
// 반환할 객체 선언
let result = {}
const db = database()
try {
await db(boardModel.tableName)
.where('key', key)
.limit(1)
.then((rows) => {
if(rows.length >0 && rows[0])
{
result = rows[0]
}
})
}
catch {
result = {}
}
return result;
}
/**
* 게시글 또는 댓글 목록 불러오기
*/
boardModel.getPost = async( params ) => {
// 게시판 고유 키
const boardKey = params?.key ?? ''
// 게시글인지, 댓글인지 여부
const type = params?.type ?? 'POST'
const parent_id = params?.parent_id ?? 0 // 댓글일 경우 부모 게시글 PK
// 검색 조건
const isNotice = params?.isNotice ? 'Y' : 'N'
const searchColumn = params?.searchColumn ?? ''
const searchQuery = params?.searchQuery ?? ''
const page = params?.page ?? 1
const pageRows = params?.page_rows ?? 10
const start = (page - 1) * pageRows
const db = database()
const t = db(boardModel.postTableName)
.select(db.raw(`SQL_CALC_FOUND_ROWS ${boardModel.postTableName}.*`))
.select('tbl_members.nickname AS created_user_name')
.leftJoin('tbl_members', `${boardModel.postTableName}.created_user`, 'tbl_members.id')
.where(`${boardModel.postTableName}.status`, 'Y')
.where(`${boardModel.postTableName}.type`, type)
.where(`${boardModel.postTableName}.is_notice`, isNotice)
// 게시판 키가 있는 경우 조건에 추가한다.
if(boardKey) {
t.where(`${boardModel.postTableName}.board_key`, boardKey)
}
// 댓글 불러오기의 경우 부모 게시글 PK를 조건에 추가한다.
if(type === 'COMMENT') {
t.where(`${boardModel.postTableName}.parent_id`, parent_id)
}
// 공지글 불러오기가 아니면서, 검색어 값이 있는 경우
if(isNotice !== 'Y' && searchColumn && searchQuery )
{
if(searchColumn === 'title') {
t.whereLike(`${boardModel.postTableName}.title`, searchQuery);
}
else if (searchColumn === 'author') {
t.where('tbl_members.nickname', searchQuery);
}
else if (searchColumn === 'title+content') {
t.where(function() {
this.where(`${boardModel.postTableName}.title`, searchQuery)
.orWhere(`${boardModel.postTableName}.content`, searchQuery)
});
}
}
// 공지가 아닐 경우 페이징 처리
if(isNotice !== 'Y')
{
t.limit(pageRows).offset(start);
}
// 반환할 객체 만들기
const result = {
result : [],
totalCount: 0
}
// 정렬 순서 설정
t.orderBy('num','desc').orderBy('reply','asc')
// 리스트 불러오기
console.log(t.toSQL());
await t.then((rows) => {
result.result = rows;
})
// 검색된 총 게시글수 불러오기
await db.raw('SELECT FOUND_ROWS() AS `cnt`')
.then(res => {
result.totalCount = res[0][0]?.cnt * 1 ?? 0
})
// 각 게시글의 번호를 달아준다.
for(let i=0; i<result.result.length; i++) {
result.result[i].num = result.totalCount - start - i;
}
return result;
}
/**
* 게시글 한개 가져오기
*/
boardModel.getPostOne = async(postId) => {
let result = {}
const db = database()
await db
.select(db.raw(`${boardModel.postTableName}.*`))
.select(db.raw(`IFNULL(tbl_members.nickname, ${boardModel.postTableName}.author_name) AS created_user_name`))
.from(boardModel.postTableName)
.leftJoin('tbl_members', `${boardModel.postTableName}.created_user`, 'tbl_members.id')
.where(boardModel.postTableName+'.id', postId)
.then((rows) => {
if(rows && rows[0]) {
result = rows[0]
}
})
return result;
}
module.exports = boardModel

15
back-end/app/modules/board/board.routes.js

@ -0,0 +1,15 @@
const router = require('express').Router()
// 컨트롤러 파일을 불러옵니다.
const controller = loadModule('board', 'controller');
router.get('/:boardKey', controller.getBoardInfo)
router.get('/:boardKey/posts', controller.getPostList)
router.get('/:boardKey/posts/:postId/comments', controller.getPostList)
router.get('/:boardKey/posts/:postId', controller.getPost)
router.post('/:boardKey/posts', controller.writeBoardPost)
router.put('/:boardKey/posts/:postId', controller.writeBoardPost)
router.post('/:boardKey/posts/:postId/hit', controller.increasePostHit)
router.delete('/:boardKey/posts/:postId', controller.deletePost)
module.exports = router

263
back-end/app/modules/users/users.controller.js

@ -0,0 +1,263 @@
const jwt = require("jsonwebtoken");
const usersController = {};
/**
* 핸드폰 번호인증
*/
usersController.phoneAuth = async(req, res) => {
// POST 요청한 body에서 값을 받아옵니다.
const phone = req.body?.phone?.replace('/-/g','') ?? '';
const code = Math.floor(100000 + Math.random() * 900000)
// 핸드폰 번호값이 없는경우
if(phone.length === 0) {
return res.status(400).json({error:'핸드폰 번호를 입력하세요'});
}
// @TODO: 실제 SMS 발송 API를 이용한 인증번호 발송 처리
// 응답 데이타를 만든다
const result = {
authCode: code
}
return res.json({result})
}
/**
* 회원가입 처리
*/
usersController.userRegister = async(req, res) => {
// 받아온 데이타를 정리한다.
let login_id = req.body?.email ?? ''
let login_pass = req.body?.password ?? ''
let login_pass_confirm = req.body?.passwordConfirm ?? ''
let nickname = req.body?.nickname ?? ''
let phone = req.body?.phone ?? ''
let agree_marketing = req.body?.agreeMarketing ? 'Y' : 'N'
let privacy_agree_at = (new Date())
// model 객체 불러오기
const usersModel = loadModule('users', 'model');
// 폼검증 처리 시작
if(nickname === '') {
return res.status(400).json({error:'[닉네임]은 필수 입력값입니다'})
}
if(login_id === '') {
return res.status(400).json({error:'[이메일주소]는 필수 입력값입니다'})
}
const emailRegex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
console.log(emailRegex.test(login_id));
if(! emailRegex.test(login_id))
{
return res.status(400).json({error:'올바른 형식의 [이메일주소]가 아닙니다'})
}
// 이미 사용중인 이메일 주소인지 체크합니다.
const check1 = await usersModel.getUser(login_id, 'login_id');
if(check1 !== null) {
return res.status(400).json({error:'이미 가입된 [이메일주소] 입니다.'})
}
if(login_pass === '') {
return res.status(400).json({error:'[비밀번호]는 필수 입력값입니다'})
}
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&amp;])[A-Za-z\d@$!%*#?&amp;]{8,}$/;
if(! passwordRegex.test(login_pass)) {
return res.status(400).json({error:'[비밀번호]는 8자 이상, 하나이상의 문자,숫자 및 특수문자를 사용하셔야 합니다'})
}
if(login_pass !== login_pass_confirm)
{
return res.status(400).json({error:'[비밀번호]와 [비밀번호 확인]이 서로 다릅니다.'})
}
const result = await usersModel.addUser({
login_id,
login_pass,
phone,
nickname,
agree_marketing,
privacy_agree_at
})
if(result) {
return res.json({result: 'success'})
}
else {
return res.status(500).json({result: 'fail'})
}
}
/**
* 사용자 로그인 처리
*/
usersController.authorize = async(req, res) => {
const loginId = req.body?.loginId ?? '',
loginPass = req.body?.loginPass ?? ''
// 아이디와 비밀번호 폼검증
if( loginId.length === 0 )
return res.status(400).json({error:'[이메일주소]를 입력하세요.'})
if( loginPass.length === 0 )
return res.status(400).json({error:'[비밀번호]를 입력하세요.'})
// user model 불러오기
const UserModel = loadModule('users', 'model')
// 해당하는 아이디의 사용자가 있는지 정보를 가져옵니다.
let user = await UserModel.getUser( loginId, 'login_id' )
// 데이타베이스에 정보가 없다면
if(user === false || user === null)
return res.status(400).json({error:'가입되지 않은 [이메일주소]이거나 [비밀번호]가 올바르지 않습니다.'})
// 비밀번호 체크를 위해 입력한 비밀번호를 암호화 합니다.
const encryptedPassword = require('sha256')(require('md5')(appConfig.secretKey + loginPass))
// DB에서 가져온 암호화된 사용자 비밀번호와 사용자가 입력한 암호화된 비밀번호가 맞지 않다면
// 아이디가 존재하지 않을 경우와 비밀번호가 다를 경우의 에러메시지를 동일하게 사용합니다.
if(user.login_pass !== encryptedPassword)
return res.status(400).json({error:'가입되지 않은 [이메일주소]이거나 [비밀번호]가 올바르지 않습니다.'})
// 회원상태가 정상이 아닌경우
if(user.status !== 'Y')
return res.status(400).json({error:'가입되지 않은 [이메일주소]이거나 [비밀번호]가 올바르지 않습니다.'})
// 모든 검증이 완료되면 토큰을 생성하여 반환한다.
return await UserModel.responseToken(user)
.then(result => {
return res.json(result);
})
}
/**
* 토큰 재생성
*/
usersController.refreshToken = async (req, res) => {
const refreshToken = req.body?.refreshToken ?? ''
const jwt = require('jsonwebtoken');
// 올바른 refreshToken 정보가 없을경우
if (!refreshToken)
return res.status(401).json({error: '사용자 로그인 정보가 유효하지 않습니다'});
// verify 메서드를 이용해서 refresh token을 검증합니다.
await jwt.verify(refreshToken, appConfig.secretKey, async (error, decoded) => {
// 검증 오류 발생시 오류를 반환합니다.
if (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({error: '사용자 로그인 정보가 유효하지 않습니다'});
}
return res.status(401).json({error: '사용자 로그인 정보가 유효하지 않습니다',});
}
const UserModel = loadModule('users', 'model')
// 검증 오류가 없는 경우 새로운 토큰을 발급해줍니다.
let user = {};
try {
// user model 불러오기
// 사용자 정보 가져오기
await UserModel.getUser(decoded.id, 'id')
.then((res) => {
user = res;
});
} catch {
user = null;
}
// 회원상태가 정상이 아닌경우
if (user === {} || user === null || user.status !== 'Y')
return res.status(400).json({error: '가입되지 않은 [이메일주소]이거나 [비밀번호]가 올바르지 않습니다.'});
// 새로운 accessToken 과 refreshToken 을 발급한다.
return await UserModel.responseToken(user).then((json) => {
return res.status(200).json(json);
});
});
}
/**
* 로그인한 사용자의 정보 가져오기
*/
usersController.getInfo = async(req, res) => {
// 미들웨어를 통해 헤더를 통해 전송받은 accessToken을 이용해 현재 로그인 사용자의 PK를 가져온다.
const loginUserId = req.loginUser?.id ?? 0
// 로그인 되지 않앗거나 잘못된 PK의 경우
if(loginUserId === undefined || loginUserId < 1) {
return res.status(400).json({error:'잘못된 접근입니다'})
}
// user model 불러오기
const UserModel = loadModule('users', 'model')
let user = {}
try {
await UserModel.getUser( loginUserId, 'id' ).then(res => {user = res})
}
catch {
user = null
}
// 회원상태가 정상이 아닌경우
if(user === {} || user === null || user.status !== 'Y')
return res.status(400).json({code:'AUTH.ERR007', error:"탈퇴한 회원이거나 접근이 거부된 회원입니다."})
return res.json(user);
}
/**
* 로그인한 사용자의 정보를 체크하는 미들웨어
*/
usersController.loginUserCheck = async(req, res, next) => {
// JWT 패키지 로드
const jwt = require('jsonwebtoken');
const ipToInt = require('ip-to-int');
// Default 값을 지정한다
req.loginUser = {
id: 0,
ip: ipToInt(req.headers['x-forwarded-for'] || req.connection.remoteAddress).toInt()
}
// 만약 토큰 재발급요청이거나, 로그인 요청의 경우 실행하지 않는다.
if(req.path === '/users/authorize/token' || req.path === '/users/authorize') {
return next();
}
// 헤더에 포함된 accessToken 값을 가져온다.
let accessToken = req.headers['Authorization'] || req.headers['authorization'];
// AccessToken이 없는 경우 비로그인 상태이므로 그대로 넘어간다.
if(! accessToken) return next();
// AccessToken 값에서 "Bearer" 값을 제거한다.
accessToken = accessToken.replace("Bearer ",'')
// AccessToken을 검증한다.
await jwt.verify(accessToken, appConfig.secretKey, async(error, decoded) => {
if (error) {
return res.status(401).json({error: '토큰 유효기간이 만료되었습니다.'});
}
else {
// 토큰검증에 성공한경우, req.loginUser 객체의 id 값을 복호화한 회원PK값으로 변경한다.
req.loginUser.id = decoded.id;
return next();
}
});
}
module.exports = usersController;

105
back-end/app/modules/users/users.model.js

@ -0,0 +1,105 @@
const usersModel = {};
usersModel.tableName = "tbl_members";
/**
* 사용자 데이타 행을 가져온다.
*/
usersModel.getUser = async(value, column="id") => {
// 데이타베이스 연결 객체
const db = database();
// 반환할 객체
let result = null
try {
await db(usersModel.tableName)
.select("*")
.select(db.raw("INET_ATON(`loged_ip`) AS `loged_ip`"))
.where(column, value)
.limit(1)
.then((rows) => {
if(rows && rows.length > 0) {
result = rows[0]
}
})
}
catch (e){
result = null;
}
return result;
}
/**
* 사용자 데이타 추가
*/
usersModel.addUser = async( data ) => {
// 혹시 빈값이 들어가있는경우 기본값 처리
data.status = data?.status ?? 'Y'
data.login_id = data?.login_id ?? ''
data.login_pass = data?.login_pass ?? ''
data.phone = data?.phone ?? ''
data.nickname = data?.nickname ?? ''
data.auth = data?.auth ?? 1
data.created_at = data?.created_at ?? new Date()
data.updated_at = data?.updated_at ?? new Date()
data.agree_marketing = data?.agree_marketing ?? 'N'
data.privacy_agree_at = data?.privacy_agree_at ?? new Date()
// 비밀번호는 암호화 처리한다.
data.login_pass = require('sha256')(require('md5')(appConfig.secretKey + data.login_pass))
// 결과값을 반환할 flag
let result = false
// 데이타베이스 객체
const db = database()
try {
await db(usersModel.tableName)
.insert(data)
.then(() => {
result = true
})
}
catch {
}
return result;
}
/**
* 토큰을 생성합니다.
*/
usersModel.createToken = async(type, userInfo) => {
const jwt = require('jsonwebtoken');
const expiresIn =
type === 'refresh'
? appConfig.jwt.refreshTokenExpire
: appConfig.jwt.accessTokenExpire
return await jwt.sign({
id: userInfo.id
}, appConfig.secretKey, {
expiresIn
})
}
/**
* 반환용 토큰을 생성합니다.
*/
usersModel.responseToken = async(userInfo) => {
let newAccessToken = '',
newRefreshToken = '';
await usersModel.createToken('access', userInfo ).then((v) => (newAccessToken = v));
await usersModel.createToken('refresh', userInfo).then((v) => (newRefreshToken = v));
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken
}
}
module.exports = usersModel;

14
back-end/app/modules/users/users.routes.js

@ -0,0 +1,14 @@
const router = require('express').Router()
// 컨트롤러 파일을 불러옵니다.
const controller = loadModule('users', 'controller');
// 경로를 지정하고 컨트롤러와 연결합니다.
router.post('/', controller.userRegister)
router.post('/authorize/phone', controller.phoneAuth)
router.get('/',controller.getInfo)
router.post('/authorize', controller.authorize)
router.post('/authorize/token', controller.refreshToken)
// 설정한 라우트 설정을 내보냅니다.
module.exports = router

1667
back-end/package-lock.json
File diff suppressed because it is too large
View File

29
back-end/package.json

@ -0,0 +1,29 @@
{
"name": "back-end",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "nodemon app --dev",
"server": "nodemon app"
},
"author": "",
"license": "ISC",
"dependencies": {
"body-parser": "^1.20.2",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"express": "^4.18.2",
"ip-to-int": "^0.3.1",
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.2",
"md5": "^2.3.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.4.1",
"randomstring": "^1.3.0",
"sha256": "^0.2.0"
},
"devDependencies": {
"nodemon": "^2.0.22"
}
}

3
front-end/.browserslistrc

@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

17
front-end/.eslintrc.js

@ -0,0 +1,17 @@
module.exports = {
root: true,
env: {
node: true
},
'extends': [
'plugin:vue/essential',
'eslint:recommended'
],
parserOptions: {
parser: '@babel/eslint-parser'
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
}
}

24
front-end/.gitignore

@ -0,0 +1,24 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
/back-end/app/data*

24
front-end/README.md

@ -0,0 +1,24 @@
# front-end
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

5
front-end/babel.config.js

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

19
front-end/jsconfig.json

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

11860
front-end/package-lock.json
File diff suppressed because it is too large
View File

35
front-end/package.json

@ -0,0 +1,35 @@
{
"name": "front-end",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@toast-ui/vue-editor": "^3.2.3",
"axios": "^1.4.0",
"core-js": "^3.8.3",
"vue": "^2.6.14",
"vue-cookies": "^1.8.3",
"vue-pagination-2": "^3.1.0",
"vue-router": "^3.5.1",
"vuejs-paginate": "^2.1.0",
"vuex": "^3.6.2"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-plugin-vuex": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"sass": "^1.32.7",
"sass-loader": "^12.0.0",
"vue-template-compiler": "^2.6.14"
}
}

BIN
front-end/public/favicon.ico

17
front-end/public/index.html

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

32
front-end/src/App.vue

@ -0,0 +1,32 @@
<template>
<div id="app">
<nav>
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</nav>
<router-view/>
</div>
</template>
<style lang="scss">
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
nav {
padding: 30px;
a {
font-weight: bold;
color: #2c3e50;
&.router-link-exact-active {
color: #42b983;
}
}
}
</style>

BIN
front-end/src/assets/logo.png

After

Width: 200  |  Height: 200  |  Size: 6.7 KiB

60
front-end/src/components/HelloWorld.vue

@ -0,0 +1,60 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
For a guide and recipes on how to configure / customize this project,<br>
check out the
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
</p>
<h3>Installed CLI Plugins</h3>
<ul>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router" target="_blank" rel="noopener">router</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
</ul>
<h3>Essential Links</h3>
<ul>
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
</ul>
<h3>Ecosystem</h3>
<ul>
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
</ul>
</div>
</template>
<script>
export default {
name: 'HelloWorld',
props: {
msg: String
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>

28
front-end/src/components/MyFormInputWrap.vue

@ -0,0 +1,28 @@
<template>
<div class="--form-wrap">
<slot />
</div>
</template>
<style lang="scss" scoped>
.--form-wrap {
flex:1;
padding:.375rem .75rem .375rem 0;
.--form-label + & {
padding-left:.375rem;
}
}
</style>
<script>
export default {
props: {
required: {
type: Boolean,
default: false,
required: false
}
}
}
</script>

41
front-end/src/components/MyFormLabel.vue

@ -0,0 +1,41 @@
<template>
<div class="--form-label">
<slot />
<span class="required ms-1" v-if="required">(필수입력)</span>
</div>
</template>
<style lang="scss" scoped>
.--form-label {
width:100px;
flex-shrink: 0;
font-size:1rem;
font-weight:700;
padding-top:.725rem;
padding-right:.375rem;
.required {
font-size:0;
display:inline-block;
vertical-align: top;
&:before {
content:'*';
color:red;
font-size:.9rem;
}
}
}
</style>
<script>
export default {
props: {
required: {
type: Boolean,
default: false,
required: false
}
}
}
</script>

12
front-end/src/components/MyFormRow.vue

@ -0,0 +1,12 @@
<template>
<div class="--form-row">
<slot />
</div>
</template>
<style lang="scss" scoped>
.--form-row {
display:flex;
width:100%;
}
</style>

29
front-end/src/components/MyInput.vue

@ -0,0 +1,29 @@
<template>
<my-form-row>
<my-form-label :required="required">{{label}}</my-form-label>
<my-form-input-wrap>
<slot />
</my-form-input-wrap>
</my-form-row>
</template>
<script>
import MyFormRow from "@/components/MyFormRow.vue";
import MyFormLabel from "@/components/MyFormLabel.vue";
import MyFormInputWrap from "@/components/MyFormInputWrap.vue";
export default {
components: {MyFormInputWrap, MyFormLabel, MyFormRow},
props: {
required: {
type: Boolean,
default: false,
required: false
},
label : {
type: String,
required: false,
default: ''
}
}
}
</script>

44
front-end/src/components/MyTextInput.vue

@ -0,0 +1,44 @@
<template>
<my-input :label="label" :required="required">
<input v-model="inputValue" @input="onUpdate" :required="required" />
</my-input>
</template>
<script>
import MyInput from "@/components/MyInput.vue";
export default {
components: {MyInput},
props: {
required: {
type: Boolean,
required: false,
default: false,
},
label: {
type: String,
required: false,
default: ''
},
value: {
type: [String, Number],
required: false,
default: ''
}
},
data () {
return {
inputValue: ''
}
},
watch: {
value () {
this.inputValue = this.value
}
},
methods: {
onUpdate () {
this.$emit('input', this.inputValue )
}
}
}
</script>

0
front-end/src/components/WysiwygEditor.vue

34
front-end/src/main.js

@ -0,0 +1,34 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
Vue.config.productionTip = false
import '@/plugins/formatter'
// 믹스인(mixin)설정을 불러와 적용합니다.
import mixins from "@/mixins";
Vue.mixin(mixins);
// Axios 모듈 로드
import '@/plugins/axios'
// Vue Cookies
import VueCookies from 'vue-cookies'
Vue.use(VueCookies)
// 새로고침등을 했을때 로그인이 되어있는지 여부를 다시 체크
import userModel from '@/models/userModel'
if(userModel.isLogin())
{
store.commit('authorize/setLogin', true)
userModel.requestMyInfo()
}
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

51
front-end/src/mixins.js

@ -0,0 +1,51 @@
import {mapGetters} from "vuex";
export default {
computed: {
...mapGetters({
isLogin: 'authorize/isLogin',
loginUser: 'authorize/loginUser'
})
},
filters: {
// 숫자를 3자리마다 콤마(,)를 붙여준다.
numberFormat (value) {
if(value === 0 || value === '0') return "0"
const reg = /(^[+-]?\d+)(\d{3})/;
let n = (value + '');
while(reg.test(n)) {
n = n.replace(reg, '$1' + ',' + '$2');
}
return n
},
// 날짜를 sns의 ~분전 형식으로 변경해준다.
snsDateTime (value) {
if(value === null || value === '') return
const today = new Date()
let date = new Date(value);
const elapsedTime = Math.trunc((today.getTime() - date.getTime()) / 1000);
let elapsedText = "";
if (elapsedTime < 10) {
elapsedText = "방금 전";
} else if (elapsedTime < 60) {
elapsedText = elapsedTime + "초 전";
} else if (elapsedTime < 60 * 60) {
elapsedText = Math.trunc(elapsedTime / 60) + "분 전";
} else if (elapsedTime < 60 * 60 * 24) {
elapsedText = Math.trunc(elapsedTime / 60 / 60) + "시간 전";
} else if (elapsedTime < (60* 60* 24 * 10)) {
elapsedText = Math.trunc(elapsedTime / 60 / 60 / 24) + "일 전";
} else {
elapsedText = value.dateFormat('yy/MM/dd')
}
return elapsedText
},
}
}

34
front-end/src/models/boardModel.js

@ -0,0 +1,34 @@
import vue from 'vue'
import axios from '@/plugins/axios'
const exportObject = {
/**
* 게시글 한개의 데이타를 가져옵니다.
* @param boardKey 게시판 고유
* @param id
* @returns {Promise<*>}
*/
getPost: async(boardKey, id) => {
return axios.get(`/board/${boardKey}/posts/${id}`)
},
/**
* 게시글의 조회수를 처리한다.
* @param boardKey
* @param postId
*/
submitHit: (boardKey, postId) => {
// 생성된 쿠키를 체크하여, 해당 쿠키가 존재한다면 실행하지 않는다.
if(vue.$cookies.get("post_hit_"+postId))
return;
axios.post(`/board/${boardKey}/posts/${postId}/hit`)
.then(() => {
vue.$cookies.set(`post_hit_${postId}`, true, 60*60*24) // 초 단위
})
},
}
export default exportObject

82
front-end/src/models/userModel.js

@ -0,0 +1,82 @@
import store from '@/store'
import axios from '@/plugins/axios'
const exportObject = {
/**
* 사용자의 로그인 여부를 확인합니다.
*/
isLogin: () => {
const accessToken = localStorage.getItem('accessToken');
return !!(accessToken && accessToken !== 'undefined');
},
/**
* REST API 서버로 로그인 요청을 보냅니다.
*/
requestLogin: async (payload) => {
return await axios
.post('/users/authorize', {
loginId: payload.loginId,
loginPass: payload.loginPass,
})
.then(async (res) => {
// 정상적으로 응답을 받은경우, processLogin 함수를 실행합니다.
await exportObject.processLogin(res.data)
})
},
/**
* 로그인이 완료 된경우, 응답데이타를 이용하여 클라이언트에 토큰을 저장합니다.
*/
processLogin: async (result) => {
// AccessToken 과 refreshToken 발급에 성공한 경우
if (result?.accessToken && result?.refreshToken) {
// LocalStorage에 accessToken과 refreshToken을 저장합니다.
localStorage.setItem('accessToken', result?.accessToken);
localStorage.setItem('refreshToken', result?.refreshToken);
// vuex 상태관리에서 현재 로그인 상태를 TRUE 로 변경합니다.
store.commit('authorize/setLogin', true);
// REST API에 내 정보를 요청합니다.
await exportObject.requestMyInfo()
}
// 발급에 실패한경우, 기존에 남아있는 데이타를 삭제합니다.
else {
// vuex 상태관리에서 현재 로그인 상태를 FALSE 로 변경합니다.
store.commit('authorize/setLogin', false);
// vuex 상태관리에서 현재 내정보를 빈값으로 로 변경합니다.
store.commit('authorize/setUserInfo', null);
// LocalStorage에 있는 데이타를 모두 삭제합니다.
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
alert('사용자 로그인에 실패하였습니다.')
}
},
/**
* 로그아웃 처리
*/
processLogOut: () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
store.commit('authorize/setLogin', false);
store.commit('authorize/setUserInfo', null);
},
/**
* REST API로 정보를 가져옵니다.
*/
requestMyInfo: async () => {
return await axios.get('/users').then(res => {
// vuex의 상태 관리에서 현재 내 정보를 처리합니다.
store.commit('authorize/setUserInfo', res.data);
})
}
}
export default exportObject

137
front-end/src/plugins/axios.js

@ -0,0 +1,137 @@
import Vue from 'vue'
import $axios from 'axios'
import store from '@/store'
import userModel from '@/models/userModel'
class AxiosExtend {
instance = null
// Token 만료 응답을 받을시, Token 재생성 요청을 다시 보내기 위한 flag
// 토큰 재생성은 무한으로 요청하지 않고, 한번만 요청하기 위해 사용합니다.
isAlreadyFetchingAccessToken = false
//
subscribers = []
constructor() {
this.instance = $axios.create({
baseURL: process.env.NODE_ENV === 'production'
? '릴리즈서버 REST API URI'
: 'http://127.0.0.1:3000',
timeout: 10000,
withCredentials: true
})
this.instance.interceptors.request.use(
config => {
// 요청 헤더에 accessToken을 추가합니다.
const accessToken = localStorage.getItem('accessToken')
// 만약 accessToken이 있다면 헤더에 토큰을 추가합니다.
if (accessToken)
config.headers.Authorization = `Bearer ${accessToken}`
return config
},
error => Promise.reject(error)
)
// REST API와의 통신에서 에러가 발생했을때 기본 처리
this.instance.interceptors.response.use(
response => {
return response;
},
async error => {
const { config } = error
const originalRequest = config // 토큰 재발급후 원래 요청을 다시 보내기 위해 사용합니다.
// 응답 코드가 401일 경우에 처리합니다.
if(error.response?.status === 401)
{
// 토큰재발급 요청을 보낸적이 없을경우
if(! this.isAlreadyFetchingAccessToken) {
this.isAlreadyFetchingAccessToken = true // 토큰 재발급요청 flag 를 TRUE로 변경해둡니다.
// 토큰 재발급 요청을 보냅니다.
await this.instance.post('/users/authorize/token', {
refreshToken: localStorage.getItem('refreshToken'),
}).then(r => {
// 토큰 재발급 요청에 성공하면 flag는 다시 true로 변경해줍니다.
this.isAlreadyFetchingAccessToken = false
// LocalStorage의 값을 업데이트 해줍니다.
localStorage.setItem('refreshToken', r.data.refreshToken)
localStorage.setItem('accessToken', r.data.accessToken)
store.commit('authorize/setLogin', true)
this.subscribers = this.subscribers.filter(callback => callback(r.data.accessToken))
})
}
// 토큰 재발급을 이미 요청했는데도 401 응답이라면?
else {
// 토큰 재발급에 실패했으므로, 저장되있던 데이타를 모두 날립니다.
window.localStorage.removeItem('accessToken')
window.localStorage.removeItem('refreshToken')
originalRequest.headers.Authorization = null
store.commit('authorize/setLogin', false)
store.commit('authorize/setUserInfo', null)
}
const retryOriginalRequest = new Promise(resolve => {
this.subscribers.push(accessToken => {
originalRequest.headers.Authorization = `Bearer ${accessToken}`
resolve(this.instance(originalRequest))
})
console.log(this.subscribers);
})
// 로그인된 상태라면 내 정보를 다시 가져옵니다.
if(userModel.isLogin()) {
await userModel.requestMyInfo()
}
return retryOriginalRequest
}
else {
let message
if(error.response?.data?.error) {
message = error.response.data.error
}
else {
switch(error.response?.status) {
case 0 :
message = "REST API 서버에 접근할 수 없습니다\n서버 관리자에게 문의하세요";
break;
case 400:
message = '잘못된 요청입니다.';
break;
case 404:
message = '[404] REST API 요청에 실패하였습니다';
break;
case 500:
message = '서버에서 처리중 오류가 발생하였습니다.'
break
default:
message = "잘못된 요청입니다.";
break
}
}
alert(message);
return Promise.reject(error);
}
}
)
}
}
const axios = new AxiosExtend()
Vue.prototype.$axios = axios.instance
export default axios.instance

51
front-end/src/plugins/formatter.js

@ -0,0 +1,51 @@
/* eslint-disable */
Date.prototype.dateFormat = function(f) {
if (!this.valueOf()) return " ";
if (!f) return this;
var weekName = ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"],
shortWeekName = ["일", "월", "화", "수", "목", "금", "토"],
d = this;
return f.replace(/(yyyy|yy|MM|dd|E|hh|mm|ss|a\/p)/gi, function($1) {
let h = 0
switch ($1) {
case "yyyy": return d.getFullYear();
case "yy": return (d.getFullYear() % 1000).zf(2);
case "MM": return (d.getMonth() + 1).zf(2);
case "dd": return d.getDate().zf(2);
case "E": return weekName[d.getDay()];
case "e": return shortWeekName[d.getDay()];
case "HH": return d.getHours().zf(2);
case "hh": return ((h = d.getHours() % 12) ? h : 12).zf(2);
case "mm": return d.getMinutes().zf(2);
case "ss": return d.getSeconds().zf(2);
case "a/p": return d.getHours() < 12 ? "오전" : "오후";
default: return $1;
}
});
};
String.prototype.string = function(len){var s = '', i = 0; while (i++ < len) { s += this; } return s;};
String.prototype.zf = function(len){return "0".string(len - this.length) + this;};
Number.prototype.zf = function(len){return this.toString().zf(len);};
String.prototype.dateFormat = function(f) {
var d = new Date(this);
return ( d == 'Invalid Date') ? '' : d.dateFormat(f);
}
/**********************************************************************************************************************
* 숫자에 컴마를 붙여서 리턴한다
* @returns {*}
*********************************************************************************************************************/
Number.prototype.numberFormat = function(){
if(this==0) return 0;
var reg = /(^[+-]?\d+)(\d{3})/;
var n = (this + '');
while (reg.test(n)) n = n.replace(reg, '$1' + ',' + '$2');
return n;
};
String.prototype.numberFormat = function() { return isNaN( parseFloat(this) ) ? "0" : (parseFloat(this)).numberFormat(); };

1
front-end/src/router/agreement.routes.js

@ -0,0 +1 @@
export default []

7
front-end/src/router/authorize.routes.js

@ -0,0 +1,7 @@
/**
* 회원 인증과 관련된 페이지
*/
export default [
{ path: '/authorize/sign-up', name: 'SignUp', component: () => import(/* webpackChunkName: "authorize.sign-up" */ '../views/Authorize/SignUp.vue') },
{ path: '/authorize/sign-in', name: 'SignIn', component: () => import(/* webpackChunkName: "authorize.sign-in" */ '../views/Authorize/SignIn.vue') },
]

14
front-end/src/router/board.routes.js

@ -0,0 +1,14 @@
export default [
{
path: '/board/:boardKey',
name: 'BoardLayout',
component: () => import(/* webpackChunkName: "board" */ '../views/Board/BoardView.vue'),
children: [
{path: '', name: 'PostList',component: () => import(/* webpackChunkName: "board.postList" */ '../views/Board/BoardPostList.vue') },
{path: 'write', name: 'PostWrite',component: () => import(/* webpackChunkName: "board.postWrite" */ '../views/Board/BoardPostWrite.vue') },
{path: ':postId', name: 'PostView',component: () => import(/* webpackChunkName: "board.postView" */ '../views/Board/BoardPostView.vue') },
{path: ':postId/edit', name: 'PostEdit',component: () => import(/* webpackChunkName: "board.postEdit" */ '../views/Board/BoardPostWrite.vue') },
{path: ':parentPostId/reply', name: 'PostReply',component: () => import(/* webpackChunkName: "board.postReply" */ '../views/Board/BoardPostWrite.vue') },
]
}
]

40
front-end/src/router/index.js

@ -0,0 +1,40 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
/** 분리되어있는 routes 파일들 **/
import AuthorizeRoutes from './authorize.routes'
import MyPageRoutes from './my.routes'
import BoardRoutes from './board.routes'
import AgreementRoutes from './agreement.routes'
const routes = [
{ path: '/', name: 'home', component: () => import(/* webpackChunkName: "home" */ '../views/HomeView.vue') },
...AuthorizeRoutes,
...MyPageRoutes,
...BoardRoutes,
...AgreementRoutes,
{ path: '*', name: 'Error404', component: () => import(/* webpackChunkName: "error404" */ '../views/Errors/Error404.vue')}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
import userModel from '@/models/userModel'
router.beforeEach((to,from, next) => {
const isRequiredLogin = to.meta?.requiredLogin === true;
// 로그인이 필요한 페이지인데 로그인이 되어 있지 않다면
if(isRequiredLogin && ! usersModel.isLogin()) {
next('/authorize/sign-in')
}
else {
next();
}
})
export default router

6
front-end/src/router/my.routes.js

@ -0,0 +1,6 @@
/**
* 마이페이지
*/
export default [
{ path: '/my', name: 'MyPage', component: () => import(/* webpackChunkName: "my.page" */ '../views/My/MyPage.vue'), meta: {requiredLogin: true} }
]

34
front-end/src/store/auth/index.js

@ -0,0 +1,34 @@
import UserModel from '@/models/userModel'
export default {
namespaced: true,
state: () => ({
isLogin: false,
loginUser: {
id: 0,
nickname: '',
auth: 0
}
}),
// mutations: state를 변경하기 위해 실행되는 것으로 비동기를 해야할 경우
mutations: {
// 사용자의 로그인 상태를 체크합니다.
setLogin (state) {
state.isLogin = UserModel.isLogin()
},
// 사용자의 정보를 저장합니다.
setUserInfo (state, payload) {
state.loginUser.id = payload?.id ?? 0;
state.loginUser.nickname = payload?.nickname ?? '';
state.loginUser.auth = payload?.auth ?? 0;
}
},
getters: {
isLogin (state) {
return state.isLogin
},
loginUser (state) {
return state.loginUser
}
}
}

21
front-end/src/store/index.js

@ -0,0 +1,21 @@
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
// 회원인증 관련 상태관리 모듈
import authorizeStore from '@/store/auth'
export default new Vuex.Store({
state: {
},
getters: {
},
mutations: {
},
actions: {
},
modules: {
authorize: authorizeStore
}
})

5
front-end/src/views/AboutView.vue

@ -0,0 +1,5 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>

65
front-end/src/views/Authorize/SignIn.vue

@ -0,0 +1,65 @@
<template>
<div class="login-wrap">
<form @submit.prevent="onLogin">
<input v-model.trim="formData.loginId" required>
<input :type="passwordInputType" v-model.trim="formData.loginPass" required>
<button type="button" @click="togglePasswordVisible">{{passwordViewButtonText}}</button>
<button type="submit">로그인</button>
<router-link to="/authorize/sign-up">회원가입</router-link>
</form>
</div>
</template>
<script>
import userModel from '@/models/userModel'
export default {
name: 'SignIn',
data () {
return {
ui: {
passwordVisible: false
},
formData: {
loginId: '',
loginPass: ''
}
}
},
computed: {
passwordInputType () {
return this.ui.passwordVisible ? 'text' : 'password'
},
passwordViewButtonText () {
return this.ui.passwordVisible ? '감추기' : '보이기'
}
},
methods: {
togglePasswordVisible () {
this.ui.passwordVisible=!this.ui.passwordVisible
},
onLogin () {
//
if(this.formData.loginId === '') {
alert('아이디를 입력하세요');
return;
}
if(this.formData.loginPass === '') {
alert('비밀번호를 입력하세요');
return;
}
userModel.requestLogin({
loginId: this.formData.loginId,
loginPass: this.formData.loginPass
}).then(() => {
// / .
this.$router.replace('/')
})
}
}
}
</script>

308
front-end/src/views/Authorize/SignUp.vue

@ -0,0 +1,308 @@
<template>
<div class="sign-up-wrap">
<h1>회원가입</h1>
<form @submit.prevent="onSubmit">
<input type="email" placeholder="이메일 주소" v-model.trim="formData.email" maxlength="64" required>
<input type="password" placeholder="비밀번호" v-model.trim="formData.password" required>
<input type="password" placeholder="비밀번호 확인" v-model.trim="formData.passwordConfirm" required>
<input type="text" placeholder="닉네임" v-model.trim="formData.nickname" maxlength="15" required />
<input type="text" placeholder="핸드폰 번호" v-model.trim="formData.phone" maxlength="15" :disabled="phoneAuth.certificated||phoneAuth.isSend" @blur="onBlurPhoneInput" @keypress="preventNonNumericInput" required />
<button type="button" :disabled="phoneAuth.isSend||phoneAuth.certificated||phoneAuth.isLoading" @click="sendPhoneCode">인증번호 발송</button>
<template v-if="phoneAuth.isSend||phoneAuth.certificated">
<input type="text" placeholder="인증번호" v-model.trim="formData.phoneAuthCode" maxlength="6" required :disabled="phoneAuth.certificated">
<button type="button" :disabled="phoneAuth.certificated||phoneAuth.isLoading" @click="checkPhoneAuth">인증 {{phoneAuth.certificated?'완료':'확인'}}</button>
<p v-if="phoneAuth.isSend">인증 유효시간:{{phoneAuth.remainTime}}</p>
</template>
<div>
<input id="agree_site" type="checkbox" v-model="formData.agreeSite">
<label for="agree_site">[필수] 사이트 이용약관에 동의합니다.</label>
</div>
<div>
<input id="agree_privacy" type="checkbox" v-model="formData.agreePrivacy">
<label for="agree_privacy">[필수] 개인정보 처리방침에 동의합니다.</label>
</div>
<div>
<input id="agree_marketing" type="checkbox" v-model="formData.agreeMarketing">
<label for="agree_marketing">[선택] 마케팅정보 수신에 동의합니다.</label>
</div>
<div>
<input id="agree_all" type="checkbox" v-model="agreeAll">
<label for="agree_all">필수 약관에 모두 동의합니다.</label>
</div>
<button type="submit">회원가입</button>
</form>
</div>
</template>
<style lang="scss" scoped>
.sign-up-wrap {
max-width:600px;
margin:0 auto;
input[type="email"],
input[type="text"],
input[type="password"] {
display:block;
width:100%;
}
}
</style>
<script>
export default {
name: 'SignUp',
data () {
return {
//
phoneAuth: {
certificated: false, //
isSend: false, //
remainTime: 0, //
remainTimeInterval: null, // setInterval
code: '', // REST API
isLoading: false // REST API flag
},
//
formData: {
email:'', //
password:'', //
passwordConfirm:'', //
nickname:'', //
phone: '', //
phoneAuthCode: '', //
agreeSite: false, //
agreePrivacy: false, //
agreeMarketing: false //
}
}
},
computed: {
/**
* 필수항목 전체 동의하기
*/
agreeAll: {
get () {
return this.formData.agreeSite && this.formData.agreePrivacy
},
set (value) {
this.formData.agreeSite = value
this.formData.agreePrivacy = value
}
}
},
methods: {
/**
* 입력박스에서 키를 입력할시 숫자만 입력가능하도록 한다.
*/
preventNonNumericInput (e) {
if (e.key !== 'Backspace' && e.key !== 'Delete' && e.key !== '-' && (e.key < '0' || e.key > '9')) {
e.preventDefault();
}
},
/**
* 올바른 휴대폰 번호인지 검증한뒤, 올바른 휴대폰 번호라면 자동으로 하이픈을 추가한다.
* @param value
* @returns {*|string}
*/
validatePhoneNumber(value) {
const phoneRegex = /^(01[016789]{1}|02|0[3-9]{1}[0-9]{1})([0-9]{3,4})([0-9]{4})$/;
const phoneWithHypenRegex = /^(01[016789]{1}|02|0[3-9]{1}[0-9]{1})-?([0-9]{3,4})-?([0-9]{4})$/;
// (-) .
var transNum = value.replace(/\s/gi, '').replace(/-/gi,'');
// 11 10 .
if(transNum.length === 11 || transNum.length === 10) {
// .
if( phoneRegex.test(transNum) ) {
// .
transNum = transNum.replace(phoneWithHypenRegex, '$1-$2-$3');
return transNum
}
}
return ''
},
/**
* 휴대폰번호 입력 Input 에서 Blur(포커스를 잃을떄) 발생하는 이벤트
*/
onBlurPhoneInput () {
if(this.formData.phone === '') return;
const transNum = this.validatePhoneNumber(this.formData.phone);
if(transNum === '') {
alert('올바른 형식의 휴대폰 번호가 아닙니다.');
this.formData.phone = ''
}
else {
this.formData.phone = transNum
}
},
/**
* 휴대폰 인증번호 발송하기
*/
sendPhoneCode () {
// .
this.phoneAuth.certificated = false;
//
if(this.phoneAuth.isLoading) {
return;
}
//
if(this.formData.phone.length === 0) {
alert('올바른 형식의 휴대폰 번호가 아닙니다.');
return;
}
// flag
this.phoneAuth.isLoading = true;
this.$axios.post('/users/authorize/phone', {
phone: this.formData.phone
}).then(res => {
this.formData.phoneAuthCode = ''
this.phoneAuth.code = res.data.result.authCode; // REST API .
this.phoneAuth.isSend = true // true
this.phoneAuth.remainTime = 180 // 180
// 1 setInterval.
// , .
this.phoneAuth.remainTimeInterval = setInterval(()=> {
if(this.phoneAuth.remainTime <= 1) {
this.resetPhoneAuth()
}
this.phoneAuth.remainTime--;
}, 1000)
}).finally(()=> {
// flag
this.phoneAuth.isLoading = false;
})
},
/**
* 인증번호 발송상태 초기화
*/
resetPhoneAuth () {
// setInterval .
if(this.phoneAuth.remainTimeInterval !== null) {
clearInterval(this.phoneAuth.remainTimeInterval);
this.phoneAuth.remainTimeInterval = null;
}
// , , flag .
this.phoneAuth.remainTime = 0
this.phoneAuth.isSend = false;
this.phoneAuth.certificated = false;
},
/**
* 입력한 인증번호가 백엔드에서 받은 인증번호와 동일한지 체크한다.
*/
checkPhoneAuth () {
if(this.formData.phoneAuthCode.toString() === this.phoneAuth.code.toString()) {
this.resetPhoneAuth();
this.phoneAuth.certificated = true
}
else {
alert('인증번호가 맞지 않습니다.');
}
},
/**
* 회원가입 폼검증 폼전송
*/
onSubmit () {
//
if(this.formData.email === '')
{
alert('이메일 주소는 필수입력입니다.');
return;
}
const emailRegex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
if(! emailRegex.test(this.formData.email))
{
alert('올바른 형식의 이메일 주소가 아닙니다');
return;
}
//
if(this.formData.nickname === '')
{
alert('닉네임은 필수입력입니다.');
return;
}
if(this.formData.nickname.length < 2 || this.formData.nickname.length > 15)
{
alert("닉네임은 2자리 이상, 15자리까지 입력가능 합니다.");
return;
}
//
if(this.formData.password === '') {
alert('비밀번호는 필수입력입니다.');
return;
}
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/;
if(! passwordRegex.test(this.formData.password)) {
alert('비밀번호는 8자 이상, 하나이상의 문자,숫자 및 특수문자를 사용하셔야 합니다');
return;
}
//
if(this.formData.password !== this.formData.passwordConfirm) {
alert('비밀번호와 비밀번호 확인이 서로 다릅니다.');
return;
}
//
if(! this.phoneAuth.certificated || this.formData.phone === '') {
alert('휴대폰 번호 인증을 완료하셔야 합니다.');
return;
}
//
if(! this.formData.agreeSite) {
alert("사이트 이용약관에 동의하셔야 합니다.");
return;
}
if(! this.formData.agreePrivacy) {
alert("개인정보 처리방침에 동의하셔야 합니다.");
return;
}
this.$axios.post('/users', {
email: this.formData.email,
password: this.formData.password,
passwordConfirm: this.formData.passwordConfirm,
nickname: this.formData.nickname,
phone: this.formData.phone,
agreeSite: this.formData.agreeSite,
agreePrivacy: this.formData.agreePrivacy,
agreeMarketing: this.formData.agreeMarketing
}).then(() => {
alert("회원 등록이 완료되었습니다.");
this.$router.replace('/authorize/sign-in')
})
}
}
}
</script>

97
front-end/src/views/Board/BoardPostList.vue

@ -0,0 +1,97 @@
<template>
<component
:is="boardInfo.type"
@onPageChange="onPageChange"
@onGetList="getPostList"
@onFilterUpdated="onFilterUpdated"
@onSearch="onSearch"
/>
</template>
<script>
import SimpleListSkin from './Skins/PostSimpleList.vue'
import GallerySkin from './Skins/PostGallery.vue'
import WebZineSkin from './Skins/PostWebzine.vue'
export default {
components: {
'LIST' : SimpleListSkin,
'GALLERY' : GallerySkin,
'WEBZINE' : WebZineSkin
},
computed: {
boardKey () {
return this.$parent.boardKey
},
boardInfo () {
// boardInfo .
return this.$parent.boardInfo
},
totalPages () {
return this.boardInfo.page_rows === 0
? 1
: Math.ceil( this.listData.totalCount / this.boardInfo.page_rows )
}
},
data () {
return {
//
listData: {
result: [], //
page: 1, //
totalCount: 0 //
},
//
filters: {
type: 'title', //
q: '' //
}
}
},
methods: {
onPageChange(page) {
this.listData.page = page
},
//
getPostList () {
// GET .
const formData = {};
formData.searchColumn = this.filters.type
formData.searchQuery = this.filters.q
formData.page = this.listData.page
formData.page_rows = this.boardInfo.page_rows
console.log(this.boardInfo);
// REST API
this.$axios.get(`/board/${this.boardKey}/posts`, {
params: formData
})
.then((res) => {
this.listData.result = res.data.result
this.listData.totalCount = res.data.totalCount
})
},
onFilterUpdated ( filters ) {
this.filters.type = filters.type
this.filters.q = filters.q
},
onSearch () {
this.listData.page = 1
this.getPostList()
}
},
mounted () {
this.$nextTick(() => {
this.getPostList();
})
},
watch: {
// .
boardKey () {
this.getPostList();
}
}
}
</script>

178
front-end/src/views/Board/BoardPostView.vue

@ -0,0 +1,178 @@
<template>
<div>
<h2>{{boardInfo.title}}</h2>
<article>
<header>
<h4>{{postInfo.title}}</h4>
<dl>
<dt>작성자</dt>
<dd>{{postInfo.created_user_name}}</dd>
</dl>
<dl>
<dt>작성일시</dt>
<dd>{{postInfo.created_at | snsDateTime}}</dd>
</dl>
<dl>
<dt>조회수</dt>
<dd>{{postInfo.hit | numberFormat}}</dd>
</dl>
</header>
<div v-if="postInfo.status === 'Y'">
<div v-html="postInfo.content"></div>
</div>
<div v-else>
<p>해당 글은 블라인드 처리되어 내용을 없습니다.</p>
</div>
</article>
<template v-if="isAuthor">
<router-link :to="`/board/${boardKey}/${postId}/edit`">수정</router-link>
<button type="button" @click="deletePost">수정</button>
</template>
<router-link :to="`/board/${boardKey}/${postId}/reply`">답글달기</router-link>
<div class="popup" v-if="ui.deleteDialogView">
<div class="popup-window">
<form @submit.prevent="deletePost">
<template v-if="postInfo.created_user===0 && loginUser.auth < 10">
<my-text-input type="password" label="비밀번호" v-model="ui.deletePassword" />
</template>
<button type="submit">삭제하기</button>
<button type="button" @click="ui.deleteDialogView=false">취소</button>
</form>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.popup {
position:fixed;
top:0;
left:0;
width:100%;
height:100%;
z-index: 10000;
&:before {
content:'';
position:absolute;
top:0;
left:0;
width:100%;
height:100%;
z-index: 10001;
background:rgba(0,0,0, 0.4);
}
.popup-window {
position:absolute;
top:50%;
left:50%;
width:400px;
height:400px;
z-index:10002;
transform:translate(-50%, -50%);
background-color:#fff;
}
}
</style>
<script>
import boardModel from "@/models/boardModel";
import MyTextInput from "@/components/MyTextInput.vue";
import {del} from "vue";
export default {
components: {MyTextInput},
data () {
return {
postInfo: {},
ui: {
deleteDialogView: false,
deletePassword: ''
}
}
},
computed: {
boardKey() {
return this.$route.params?.boardKey ?? ''
},
postId () {
return this.$route.params?.postId ?? 0;
},
boardInfo () {
return this.$parent.boardInfo
},
// /
isAuthor () {
// true
if(this.loginUser.auth >= 10)
return true;
// , true
if(this.boardInfo.created_user === 0 && this.loginUser.id === 0 ) {
return true;
}
// PK PK true
if(this.boardInfo.created_user === this.loginUser.id) {
return true;
}
return false;
}
},
mounted () {
this.$nextTick(() => {
this.getPostInfo()
})
},
watch: {
postId () {
this.getPostInfo()
},
boardKey () {
this.getPostInfo()
}
},
methods: {
del,
deletePost () {
//
if(!this.ui.deleteDialogView) {
this.ui.deleteDialogView = true;
return;
}
// REST API .
// , , .
if( this.boardInfo.created_user === 0 && this.loginUser.auth < 10 ) {
if(this.ui.deletePassword === '') {
alert('글 작성시 입력한 비밀번호를 입력하세요');
return;
}
}
// REST API
this.$axios.delete(`/board/${this.boardKey}/posts/${this.postId}`, {
password: this.ui.deletePassword
})
.then(() => {
alert('글이 삭제되었습니다.');
this.$router.replace(`/board/${this.boardKey}`);
})
},
getPostInfo () {
boardModel.getPost(this.boardKey, this.postId)
.then(res => {
this.postInfo = res.data.result
//
boardModel.submitHit(this.boardKey, this.postId)
})
}
}
}
</script>

268
front-end/src/views/Board/BoardPostWrite.vue

@ -0,0 +1,268 @@
<template>
<form @submit.prevent="onSubmit">
<my-text-input label="제목" v-model="formData.title" required />
<!-- S: 비회원일 경우 닉네임과 비밀번호를 수동으로 입력합니다. -->
<template v-if="loginUser.id <= 0">
<my-text-input label="닉네임" v-model.trim="formData.author_name" required />
<my-text-input type="password" label="비밀번호" v-model.trim="formData.author_pass" required />
</template>
<!-- E: 비회원일 경우 닉네임과 비밀번호를 수동으로 입력합니다. -->
<!-- S: 로그인한 사용자의 auth 값이 10 이상일 경우 공지사항 체크박스 보이기 -->
<my-input label="공지사항">
<input type="checkbox" v-model="formData.is_notice" id="is_notice">
<label for="is_notice">공지사항</label>
</my-input>
<!-- E : 로그인한 사용자의 auth 값이 10 이상일 경우 공지사항 체크박스 보이기 -->
<my-input label="글내용">
<editor
height="auto"
ref="editor"
v-model="formData.content"
initialEditType="wysiwyg"
:options="editorOption"
@change="onEditorChange"
/>
</my-input>
<my-input label="첨부파일">
<input type="file" id="fileInput" ref="fileInputRef" style="display:none" multiple @change="onFileInputChange" />
<ul>
<li v-for="(attach,index) in formData.attach_list" :key="`attach-${index}`">
<a :href="attach.file_url" target="_blank">{{attach.original_name}}</a>
<button type="button" @click="removeAttach(index)">파일삭제</button>
</li>
</ul>
<span v-if="ui.isUploading">파일을 업로드 중입니다.</span>
<label v-else for="fileInput">+ 파일 추가</label>
</my-input>
<button type="submit"> 작성하기</button>
</form>
</template>
<script>
import MyTextInput from "@/components/MyTextInput.vue";
import MyInput from "@/components/MyInput.vue";
import boardModel from '@/models/boardModel'
// TOAST UI
import '@toast-ui/editor/dist/toastui-editor.css';
import { Editor } from '@toast-ui/vue-editor';
import '@toast-ui/editor/dist/i18n/ko-kr';
export default {
components: {MyTextInput, MyInput, Editor},
data () {
return {
ui: {
isEditorChanging: false,
isUploading: false
},
formData: {
title: '',
is_notice: false,
author_name: '',
author_pass: '',
content:'',
attach_list: []
},
editorOption: {
language: 'ko-KR',
hideModeSwitch: true,
initialEditType:'wysiwyg',
}
}
},
computed: {
boardKey() {
return this.$route.params?.boardKey ?? ''
},
// postId
postId () {
return this.$route.params?.postId ?? 0;
},
// postId
postParentId () {
return this.$route.params?.parentPostId ?? 0;
},
// postId ,
isEdit () {
return this.postId > 0
},
// postParentId
isReply () {
return !this.isEdit && this.postParentId > 0
}
},
mounted () {
// Computed $nextTick
this.$nextTick(() => {
//
if(this.isEdit) {
//
boardModel.getPostInfo(this.postId)
.then((res) => {
for(let key in res.data.result) {
// formData .
if(this.formData[key] !== 'undefined') {
this.formData[key] = res.data.result[key]
}
}
// .
this.formData.author_pass = ''
})
// ,
.catch(() => {
alert('수정하려는 글의 정보를 불러올 수 없습니다. 존재하지 않거나, 이미 삭제되었습니다.');
this.$router.back();
})
}
//
else if(this.isReply) {
//
boardModel.getPostInfo(this.postParentId)
.then((res) => {
this.formData.title = "[RE] " + res.data.result.title
})
//
.catch(() => {
alert('답글을 달려는 글의 정보를 불러올 수 없습니다. 존재하지 않거나, 이미 삭제되었습니다.');
//this.$router.back();
})
}
})
},
methods: {
// , vue formData.content .
onEditorChange() {
if(this.ui.isEditorChanging) return;
this.ui.isEditorChanging = true;
this.formData.content = this.$refs.editor.invoke('getHTML');
this.ui.isEditorChanging = false;
},
// .
removeAttach (index) {
if(this.formData.attach_list.length > index + 1) {
alert('해당 파일이 존재하지 않거나 이미 삭제되었습니다.');
return;
}
//
this.$axios.delete('/attaches', {
params: {
file: this.formData.attach_list[index].file_path
}
}).finally(() => {
//
// ,
// finally
this.formData.attach_list.splice(index, 1);
})
},
//
async onFileInputChange () {
const files = this.$refs.fileInputRef.files
// .
if(files && files.length > 0)
{
// binary FormData
const formData = new FormData();
// FormData
for(let i=0; i<files.length; i++) {
formData.append('userfile', files[i]);
}
//
this.ui.isUploading = true;
// POST
await this.$axios.post(`/attaches`, formData, {
headers: {
"Content-Type": "multipart/form-data" // multipart/form-data
}
}).then((res) => {
// , .
for(let i=0; i<res.data.length; i++ ) {
this.formData.attach_list.push(res.data[i])
}
// .
this.$refs.fileInputRef.value = ''
}).finally(() => {
// flag false
this.ui.isUploading = false;
})
}
},
//
onSubmit () {
if(this.formData.title.length === 0) {
alert('글 제목은 필수로 입력하셔야 합니다.')
return;
}
if(this.formData.content.length === 0) {
alert('글 내용은 필수로 입력하셔야 합니다.')
return;
}
//
if(this.loginUser.id <= 0)
{
if(this.formData.author_name === '') {
alert('닉네임은 필수로 입력하셔야 합니다.')
return;
}
if(this.formData.author_pass === '') {
alert('비밀번호는 필수로 입력하셔야 합니다.')
return;
}
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/;
if(! passwordRegex.test(this.formData.author_pass)) {
alert('비밀번호는 8자 이상, 하나이상의 문자,숫자 및 특수문자를 사용하셔야 합니다');
return;
}
}
// , URL . ( .)
const requestUri = `/board/${this.boardKey}/posts` + ( this.isEdit ? `/${this.postId}` : '' );
// .
const formData = this.formData
// .
if(this.isReply) {
formData.parent_id = this.postParentId * 1
}
this.$axios({
method: this.isEdit ? 'PUT' : 'POST',
url: requestUri,
data: formData
}).then((res) => {
const postId = res.data.result?.id ?? 0
//
if(postId > 0) {
this.$router.replace(`/board/${this.boardKey}/${postId}`);
} else {
this.$router.replace(`/board/${this.boardKey}`);
}
})
}
}
}
</script>

67
front-end/src/views/Board/BoardView.vue

@ -0,0 +1,67 @@
<template>
<article>
<header>
{{boardInfo.title}} 게시판 상단 부분 입니다.
</header>
<!-- S: 이부분이 각각 목록/내용보기/작성하기 컴포넌트가 렌더링 됩니다 -->
<router-view v-if="isLoaded" />
<!-- E: 이부분이 각각 목록/내용보기/작성하기 컴포넌트가 렌더링 됩니다 -->
<footer>
{{boardInfo.title}} 게시판 하단 부분 입니다.
</footer>
</article>
</template>
<script>
export default {
computed: {
boardKey () {
return this.$route.params.boardKey
}
},
data () {
return {
isLoaded: false,
boardInfo: {
title: '',
type: 'LIST',
auth_list: 0,
auth_view: 0,
auth_write: 0,
auth_comment: 0,
page_rows: 0,
category_info: []
}
}
},
methods: {
async getBoardInfo () {
// REST API .
await this.$axios
.get(`/board/${this.boardKey}`)
.then((result) => {
// result .
for(let key in result.data.result) {
//
if(typeof this.boardInfo[key] !== 'undefined') {
this.boardInfo[key] = result.data.result[key];
}
}
this.isLoaded = true;
})
}
},
mounted () {
// .
this.getBoardInfo();
},
watch: {
boardKey () {
// boardKey .
this.getBoardInfo()
}
}
}
</script>

3
front-end/src/views/Board/Skins/PostGallery.vue

@ -0,0 +1,3 @@
<template>
<div>갤러리 리스트 스킨입니다.</div>
</template>

70
front-end/src/views/Board/Skins/PostSimpleList.vue

@ -0,0 +1,70 @@
<template>
<div>
<table>
<thead>
<tr>
<th>#</th>
<th>제목</th>
<th>작성자</th>
<th>작성일시</th>
<th>조회수</th>
</tr>
</thead>
<tbody>
<template v-if="listData.result.length === 0">
<tr>
<td colspan="5">등록된 글이 없습니다.</td>
</tr>
</template>
<template v-else>
<tr v-for="row in listData.result" :key="row.id">
<td>
<span v-if="row.is_notice==='Y'">[공지]</span>
<span>{{row.num | numberFormat}}</span>
</td>
<td><router-link :to="`/board/${row.board_key}/${row.id}`">{{row.title}}</router-link></td>
<td>{{row.created_user_name}}</td>
<td>{{row.created_at | snsDateTime}}</td>
<td>{{row.hit | numberFormat}}</td>
</tr>
</template>
</tbody>
</table>
<paginate
v-model="listData.page"
:page-count="totalPages"
:click-handler="getList" />
</div>
</template>
<script>
import Paginate from 'vuejs-paginate';
export default {
name:'SimpleListSkin',
components: {Paginate },
computed: {
totalPages () {
return this.$parent.totalPages;
},
boardKey () {
return this.$parent.boardKey
},
boardInfo () {
return this.$parent.boardInfo
},
listData () {
return this.$parent.listData
}
},
methods: {
getList () {
this.$emit('onGetList')
}
},
filters: {
}
}
</script>

3
front-end/src/views/Board/Skins/PostWebzine.vue

@ -0,0 +1,3 @@
<template>
<div>웹진 리스트 스킨입니다.</div>
</template>

9
front-end/src/views/Errors/Error404.vue

@ -0,0 +1,9 @@
<template>
<div>404 Not Found</div>
</template>
<script>
export default {
name: "404ErrorPage"
}
</script>

30
front-end/src/views/Errors/Test.vue

@ -0,0 +1,30 @@
<<template>
<ul>
<select v-model="category">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
<li v-for="item in computedList">{{item.value}}</li>
</ul>
</template>
<script>
export default {
data () {
return {
category: '1',
list: [
{category: '1', value:'햄버거'},
{category: '1', value:'콜라'},
{category: '2', value:'사이다'}
]
}
},
computed: {
computedList() {
return this.list.filter(item => item.category === this.category)
}
}
}
</script>>

18
front-end/src/views/HomeView.vue

@ -0,0 +1,18 @@
<template>
<div class="home">
<img alt="Vue logo" src="../assets/logo.png">
<HelloWorld msg="Welcome to Your Vue.js App"/>
</div>
</template>
<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'
export default {
name: 'HomeView',
components: {
HelloWorld
}
}
</script>

0
front-end/src/views/My/MyPage.vue

4
front-end/vue.config.js

@ -0,0 +1,4 @@
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true
})
Loading…
Cancel
Save