diff --git a/DM/app.py b/DM/app.py index fa2a602..77bd829 100644 --- a/DM/app.py +++ b/DM/app.py @@ -1,12 +1,18 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from flask import Flask, jsonify, request +import random +import pickle +import base64 +import json +import pandas as pd + +from flask import Flask, jsonify, request, make_response from flask_restx import Resource, Api, reqparse +from flask_cors import CORS import numpy as np from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer, TfidfVectorizer from sklearn.metrics.pairwise import linear_kernel, cosine_similarity -from collections import Counter from database import Database @@ -14,84 +20,327 @@ app = Flask(__name__) api = Api(app) app.config['DEBUG'] = True +cors = CORS(app, resources={r"/dm/*": {"origins": "*"}}, supports_credentials=True) + np.set_printoptions(threshold=np.inf, linewidth=np.inf) -@app.route('/api/country', methods=['GET']) + +@app.route('/dm/recommend', methods=['GET']) def getCountry(): - _input = request.args['email'] + user_id = request.args['email'] - # Database 접속 -> 크롤링 요약 데이터 가져오기 + # Database 접속 db = Database() - res = db.select(''' - select c.id, c.name, cd.contents - from country_data as cd, country as c - where cd.id=c.id - order by 1; - ''') - - country_word_list = [] - country_cnt = Counter([]) - country_name = [] - # ID, NAME, CONTENTS - for row in res: - country_name.append(row[1]) - crawl_data = eval(row[2]) - country_cnt += crawl_data - - # '호텔': 665 -> 호텔 665번 나오게 됨 - word_list = list(Counter(crawl_data).elements()) - country_word_list.append(" ".join(word_list)) - - country_name = np.array(country_name) - - vectorizer = TfidfVectorizer() # 상위 500단어 추출 - tfidf_matrix = vectorizer.fit_transform(country_word_list) - - cosine_sim = linear_kernel(tfidf_matrix, tfidf_matrix) - cosine_sim = np.array(cosine_sim) + + + # 여행지 이름 찾기 + sql = 'select id, name from country order by 1;' + country = pd.read_sql_query(sql, db.conn) + country_name = np.array(country['name']) + + # 사용자 별점정보 조회 - user_info = dict() # 이메일과 index 매칭 - user_data = dict() - res = db.select(''' - select user_id, country_id, rating - from member_rating + sql = f''' + select c.user_id, c.id, ifnull(mr.rating, 0.0) as rating + from member_rating as mr right outer join (select member.email as user_id, country.* from member right outer join country on 1=1) as c + on mr.user_id=c.user_id and mr.country_id=c.id + where c.user_id="{user_id}" order by 1, 2; - ''') + ''' + df = pd.read_sql_query(sql, db.conn) - # 'seo5220@naver.com': [0, 0, 0, 0, 0, 0, 0, 0, 0, - for item in res: - index = item[0] - i_country = int(item[1]) - i_rating = int(item[2]) - if index not in user_data: - user_data[index] = np.array([0 for cn in country_name]) - user_data[index][i_country] = i_rating - # print(user_data) + # index는 행을 의미 + user_data = pd.pivot_table(df, index='user_id', columns='id', values='rating') + user_ratings = user_data.to_numpy().reshape(-1) - # 행=유저수, 열=Country수 - user_ratings = np.zeros((len(user_data.keys()), len(country_name))) - for idx, key in enumerate(user_data.keys()): - user_ratings[idx] = user_data[key] - # user_info는 이메일<->index값 - user_info[idx] = key - user_info[key] = idx - # print(user_info) - travel_cosim = cosine_similarity(user_ratings, cosine_sim) - _index = user_info[_input] - # 본인이 갔다왔던 여행지는 제외 - except_index = np.where(user_data[_input] > 0) - travel_cosim[_index][except_index] = 0 + # Content기반 예측평점 계산 + with open('data.pickle', 'rb') as f: + content_similarities = pickle.load(f) + content_similarities = np.array(content_similarities) + + + # 하이브리드 추천시스템 (CF X Content기반) + # 유저의 예측 별점계산 + user_predicted_ratings = np.zeros((len(country))) + for item in range(len(country)): + # 이미 별점이 있는 경우 + if user_ratings[item] != 0: + user_predicted_ratings[item] = user_ratings[item] + continue + + # Content기반 필터링 + similar_countries = np.argsort(content_similarities[item])[::-1] + + # 유저가 매긴 별점이 있는 유사한 여행지만 추출 + rated_similar_country = [] + for country in similar_countries: + if user_ratings[country] != 0: + rated_similar_country.append(country) + # 유사한 여행지 상위 5개로 제한 + if len(rated_similar_country) == 5: + break + + # 아무런 평가도 하지 않았을 경우 + if len(rated_similar_country) == 0: + # 무작위 5개 여행지에 대해 정규분포 별점부여 + rnd_cnt = 5 + rated_similar_country = np.random.randint(0, len(country_name), rnd_cnt) + + for i in range(rnd_cnt): + c_index = rated_similar_country[i] + # 1.5~4.5 사이의 난수를 발생 + rnd_score = np.random.uniform(1.5, 4.5) + user_ratings[c_index] = rnd_score + + + weighted_ratings = 0 + similarity_sum = 0 + + # Hybrid Filtering + # 콘텐츠 기반 필터링이 적용된 상태 + for country in rated_similar_country: + similarity = content_similarities[item][country] + weighted_ratings += user_ratings[country] * similarity + similarity_sum += similarity + + predict_rating = weighted_ratings / similarity_sum + user_predicted_ratings[item] = predict_rating + - score_indics = np.argsort(travel_cosim[_index])[::-1] - output = country_name[score_indics][:10] + + # 유저가 이미 평가한 여행지는 제외 + gone_coutries_index = np.where(user_ratings > 0) + user_predicted_ratings[gone_coutries_index] = 0 + + # print(user_ratings) + # print(user_predicted_ratings) + recommend_country_index = np.argsort(user_predicted_ratings)[::-1] + recommend_country_index = np.random.choice(recommend_country_index[:20], 10, replace=False) + recommend_country = country_name[recommend_country_index] + + print(recommend_country) db.close() return jsonify({ - 'result': list(output) + 'result': list(recommend_country) + }) + +@app.route('/dm/companion', methods=['GET']) +def getCompanion(): + # get 파라미터 + user_id = request.args['user_id'] + country_id = int(request.args['country_id']) + + + db = Database() + + # Content기반 예측평점 계산 + with open('data.pickle', 'rb') as f: + content_similarities = pickle.load(f) + content_similarities = np.array(content_similarities) + + sql = f''' + select c.user_id, c.id, ifnull(mr.rating, 0.0) as rating + from member_rating as mr right outer join (select member_info.id as user_id, country.* from member_info right outer join country on 1=1) as c + on mr.user_id=c.user_id and mr.country_id=c.id + order by 1, 2; + ''' + + df = pd.read_sql_query(sql, db.conn) + user_data = pd.pivot_table(df, index='user_id', columns='id', values='rating') + user_ratings = user_data.to_numpy() + + try: + user_index = user_data.index.get_loc(user_id) + except: + return jsonify({ + 'result': [] + }) + coutry_list = user_data.columns.to_list() + + user_info = dict() + for u in user_data.index.to_list(): + # user_info는 이메일<->index값 + idx = user_data.index.get_loc(u) + user_info[idx] = u + user_info[u] = idx + + + + # STEP1. CF 필터링 (특정 여행지에 대해 유사한 상위 K개의 여행지를 추출 + K = 20 + similar_countries = np.argsort(content_similarities[country_id])[::-1] + similar_countries = similar_countries[:K] + + # 유저들의 예측 별점계산 + user_predicted_ratings = np.zeros((len(user_ratings), len(similar_countries))) + + + for idx, item in enumerate(similar_countries): + for user in range(len(user_ratings)): + # 이미 별점이 있는 경우 + if user_ratings[user][item] != 0: + user_predicted_ratings[user][idx] = user_ratings[user][item] + continue + + + # 유저가 별점을 매긴 여행지 & 특정여행지와 유사한나라의 교집합 + user_rated_index = np.nonzero(user_ratings[user])[0] + rated_similar_country = np.intersect1d(similar_countries, user_rated_index) + + + # 아무런 평가도 하지 않았을 경우 + if len(rated_similar_country) == 0: + # 무작위 5개 여행지에 대해 별점부여 + rnd_cnt = 5 + rated_similar_country = np.random.randint(0, len(similar_countries), rnd_cnt) + + for i in range(rnd_cnt): + c_index = rated_similar_country[i] + # 1.5~4.5 사이의 난수를 발생 + rnd_score = np.random.uniform(1, 5) + user_ratings[user][c_index] = rnd_score + + + weighted_ratings = 0 + similarity_sum = 0 + + # Hybrid Filtering + # 콘텐츠 기반 필터링이 적용된 상태 + for country in rated_similar_country: + similarity = content_similarities[country_id][country] + weighted_ratings += user_ratings[user][country] * similarity + similarity_sum += similarity + + predict_rating = weighted_ratings / similarity_sum + user_predicted_ratings[user][idx] = predict_rating + + # print(similar_countries) + # print(user_predicted_ratings) + + # STEP2. 피어슨 유사도 + pearson_sim = np.zeros((len(user_ratings), len(user_ratings))) + for user in range(len(user_ratings)): + for other in range(len(user_ratings)): + if user == other: + pearson_sim[user][other] = 1.0 + continue + + u1_c = user_predicted_ratings[user] - np.mean(user_predicted_ratings[user]) + u2_c = user_predicted_ratings[other] - np.mean(user_predicted_ratings[other]) + + + denom = np.sqrt(np.sum(u1_c ** 2) * np.sum(u2_c ** 2)) + if denom != 0: + pearson_sim[user][other] = np.sum(u1_c * u2_c) / denom + else: + pearson_sim[user][other] = 0.0 + + + + # STEP3. 취향행렬 + # user_data: 취향정보 string 배열 + # user_data_gender: 혼성,동성 정보 배열 + + user_data_list = list() + user_data_gender = list() + sql = ''' + select mf.*, m.gender + from member_info as mf, member as m + where mf.id=m.email + order by 1; + ''' + res = db.select(sql) + for idx, item in enumerate(res): + info = ";".join(item[1:4]) + user_data_list.append(info) + # 혼성/동성, 본인의 gender + user_data_gender.append([item[4], item[5]]) + + + # print(user_data) + # print(user_data_gender) + + vectorizer = TfidfVectorizer() + tfidf_matrix = vectorizer.fit_transform(user_data_list) + + # 이미 행렬이 정규화된 상태이므로 linear_kernel + user_cosine_sim = linear_kernel(tfidf_matrix, tfidf_matrix) + user_cosine_sim = np.array(user_cosine_sim) + # print(user_cosine_sim) + + + # STEP3. 위 두개의 행렬계산 + # rating_distance는 평가하지 않은 사람들이 모두 동일하게 나오는 단점 + # 따라서 비율을 조정함(30%) + combine_scores = pearson_sim*0.3 + user_cosine_sim*0.7 + + # input: user_id + companion_index = np.argsort(combine_scores[user_index])[::-1] + + # 동성/혼성 조건 + # 혼성으로 설정했으면 제외 + include_index = list() + for idx in companion_index[1:]: + if user_data_gender[user_index][0] == '혼성' or len(user_data_gender[user_index][0]) == 0: + if user_data_gender[idx][0] == '혼성': + include_index.append(idx) + elif user_data_gender[user_index][1] == user_data_gender[idx][1]: # 성이 같을때만 + include_index.append(idx) + elif user_data_gender[user_index][0] == '동성': + if user_data_gender[user_index][1] == user_data_gender[idx][1]: # 성이 같을때만 + include_index.append(idx) + + + # 본인은 제외 (최대 10명까지) + max_len = min(10, len(include_index)) + include_index = include_index[:max_len] + companion_list = list() + + + for idx in include_index: + companion_list.append(user_info[idx]) + + + # 유저 정보 조회 + _str = ",".join('"'+x+'"' for x in companion_list) + sql = f''' + select email, name, phone_number, gender, birth, mbti, profile + from member + where email in ({_str}) + order by FIELD(email, {_str}); + ''' + res = db.select(sql) + + + result = list() + for item in res: + profile = item[6] + if profile != None: + profile = base64.b64encode(item[6]).decode('utf-8') + + + info = { + 'user_id': item[0], + 'name': item[1], + 'phone': item[2], + 'gender': item[3], + 'birth': item[4], + 'mbti': item[5], + 'profile': profile + } + result.append(info) + + # print(companion_list) + # print(result) + + return jsonify({ + 'result': result }) + if __name__ == '__main__': - app.run(host='0.0.0.0') \ No newline at end of file + app.run(host='0.0.0.0', port=5000) diff --git a/DM/csv_to_db.py b/DM/csv_to_db.py index ba07c00..8388e9c 100644 --- a/DM/csv_to_db.py +++ b/DM/csv_to_db.py @@ -6,11 +6,16 @@ import pandas as pd from collections import Counter from datetime import datetime +import pickle from konlpy.tag import Komoran, Okt, Mecab from database import Database import platform +import numpy as np +from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer, TfidfVectorizer +from sklearn.metrics.pairwise import linear_kernel, cosine_similarity + #데이터 전처리 함수 def preprocessing(review): @@ -55,6 +60,8 @@ def preprocessing(review): # Database에서 country id 가져오기 db = Database() + country_word_list = [] + for file in file_list: country_name = file.split('_')[1] res = db.select(f'select id from country where name="{country_name}"') @@ -64,6 +71,7 @@ def preprocessing(review): country_id = res[0][0] # pandas csv 파일 읽기 + # Buffer overflow 관련 오류로 lineterminator 파라미터 추가 data = pd.read_csv(crawl_path + file) word_set = [] @@ -76,24 +84,32 @@ def preprocessing(review): continue print(e) - + # print(wc) wc = dict(Counter(word_set).most_common()) - wc = dict(filter(lambda x:x[1] > 10, wc.items())) # 10번 이상 들어간 값만 추출 - # print(wc) - # print(f"{country_id}_{country_name} : LENGTH={len(str(wc))}") - # print("="*50) + country_word_list.append(" ".join(word_set)) # pickle로 저장할 데이터 + print(f"{country_id}_{country_name} : LENGTH={len(str(wc))}") + print("=" * 50) # Database 데이터 insert (값이 있으면 UPDATE) cur_time = datetime.today().strftime("%Y/%m/%d %H:%M:%S") query = f'INSERT INTO country_data VALUES({country_id}, "{str(wc)}", now())' \ f'ON DUPLICATE KEY UPDATE id="{country_id}", contents="{str(wc)}", upload_time=now();' - db.query(query) + # db.query(query) + db.close() + # TF-IDF벡터 pickle 파일로 저장 + vectorizer = TfidfVectorizer(max_features=500) # 상위 500단어 추출 + tfidf_matrix = vectorizer.fit_transform(country_word_list) - db.close() + cosine_sim = linear_kernel(tfidf_matrix, tfidf_matrix) + cosine_sim = np.array(cosine_sim) + # Cosine 벡터 pickle로 저장 + with open('data.pickle', 'wb') as f: + pickle.dump(cosine_sim, f) + print('data.pickle 저장 완료') \ No newline at end of file diff --git a/DM/data.pickle b/DM/data.pickle new file mode 100644 index 0000000..d2a2bda Binary files /dev/null and b/DM/data.pickle differ diff --git a/DM/requirements.txt b/DM/requirements.txt index 1e4ba16..ab7734b 100644 --- a/DM/requirements.txt +++ b/DM/requirements.txt @@ -1,42 +1,81 @@ aniso8601==9.0.1 -asn1crypto==0.24.0 -attrs==22.2.0 -beautifulsoup4==4.12.0 -click==8.0.4 -cryptography==2.1.4 -dataclasses==0.8 -Flask==2.0.3 +attrs==19.3.0 +Automat==0.8.0 +blinker==1.6.2 +certifi==2019.11.28 +chardet==3.0.4 +click==8.1.3 +cloud-init==23.1.2 +colorama==0.4.3 +command-not-found==0.3 +configobj==5.0.6 +constantly==15.1.0 +cryptography==2.8 +dbus-python==1.2.16 +distro==1.4.0 +distro-info===0.23ubuntu1 +ec2-hibinit-agent==1.0.0 +entrypoints==0.3 +Flask==2.3.1 +Flask-Cors==3.0.10 flask-restx==1.1.0 -idna==2.6 -importlib-metadata==4.8.3 -itsdangerous==2.0.1 -Jinja2==3.0.3 -joblib==1.1.1 -JPype1==1.3.0 -JPype1-py3==0.5.5.4 -jsonschema==4.0.0 -keyring==10.6.0 -keyrings.alt==3.0 -konlpy==0.6.0 -lxml==4.9.2 -MarkupSafe==2.0.1 -mecab-python===0.996-ko-0.9.2 -numpy==1.19.5 -pandas==1.1.5 -pycrypto==2.6.1 -pygobject==3.26.1 -PyMySQL==1.0.2 -pyrsistent==0.18.0 -python-dateutil==2.8.2 -python-dotenv==0.20.0 +hibagent==1.0.1 +httplib2==0.14.0 +hyperlink==19.0.0 +idna==2.8 +importlib-metadata==6.6.0 +incremental==16.10.1 +itsdangerous==2.1.2 +Jinja2==3.1.2 +joblib==1.2.0 +jsonpatch==1.22 +jsonpointer==2.0 +jsonschema==3.2.0 +keyring==18.0.1 +language-selector==0.1 +launchpadlib==1.10.13 +lazr.restfulclient==0.14.2 +lazr.uri==1.0.3 +MarkupSafe==2.1.2 +more-itertools==4.2.0 +netifaces==0.10.4 +numpy==1.24.3 +oauthlib==3.1.0 +pexpect==4.6.0 +pyasn1==0.4.2 +pyasn1-modules==0.2.1 +PyGObject==3.36.0 +PyHamcrest==1.9.0 +PyJWT==1.7.1 +pymacaroons==0.13.0 +PyMySQL==1.0.3 +PyNaCl==1.3.0 +pyOpenSSL==19.0.0 +pyrsistent==0.15.5 +pyserial==3.4 +python-apt==2.0.1+ubuntu0.20.4.1 +python-debian===0.1.36ubuntu1 +python-dotenv==1.0.0 pytz==2023.3 -pyxdg==0.25 -scikit-learn==0.24.2 -scipy==1.5.4 +PyYAML==5.3.1 +requests==2.22.0 +requests-unixsocket==0.2.0 +scikit-learn==1.2.2 +scipy==1.10.1 SecretStorage==2.3.1 -six==1.16.0 -soupsieve==2.3.2.post1 +service-identity==18.1.0 +simplejson==3.16.0 +six==1.14.0 +sos==4.4 +ssh-import-id==5.10 +systemd-python==234 threadpoolctl==3.1.0 -typing-extensions==4.1.1 -Werkzeug==2.0.3 -zipp==3.6.0 +Twisted==18.9.0 +ubuntu-advantage-tools==8001 +ufw==0.36 +unattended-upgrades==0.1 +urllib3==1.25.8 +wadllib==1.3.3 +Werkzeug==2.3.2 +zipp==1.0.0 +zope.interface==4.7.1 diff --git a/DM/vectorization.py b/DM/vectorization.py index b144a5d..93e23ff 100644 --- a/DM/vectorization.py +++ b/DM/vectorization.py @@ -1,90 +1,322 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- import math +import base64 import numpy as np +import pandas import pandas as pd +import pickle +import random from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer, TfidfVectorizer from sklearn.metrics.pairwise import linear_kernel, cosine_similarity -from collections import Counter from database import Database np.set_printoptions(threshold=np.inf, linewidth=np.inf) +def getCountry(): + # Database 접속 + db = Database() -if __name__ == "__main__": - # Database 접속 -> 크롤링 요약 데이터 가져오기 + # 여행지 이름 찾기 + sql = 'select id, name from country order by 1;' + country = pd.read_sql_query(sql, db.conn) + country_name = np.array(country['name']) + + + # 사용자 별점정보 조회 + user_id = 'test' + sql = f''' + select c.user_id, c.id, ifnull(mr.rating, 0.0) as rating + from member_rating as mr right outer join (select member.email as user_id, country.* from member right outer join country on 1=1) as c + on mr.user_id=c.user_id and mr.country_id=c.id + where c.user_id="{user_id}" + order by 1, 2; + ''' + df = pd.read_sql_query(sql, db.conn) + + # index는 행을 의미 + user_data = pd.pivot_table(df, index='user_id', columns='id', values='rating') + user_ratings = user_data.to_numpy().reshape(-1) + + + # Content기반 예측평점 계산 + with open('data.pickle', 'rb') as f: + content_similarities = pickle.load(f) + content_similarities = np.array(content_similarities) + + + # 하이브리드 추천시스템 (CF X Content기반) + # 유저의 예측 별점계산 + user_predicted_ratings = np.zeros((len(country))) + for item in range(len(country)): + # 이미 별점이 있는 경우 + if user_ratings[item] != 0: + user_predicted_ratings[item] = user_ratings[item] + continue + + # Content기반 필터링 + similar_countries = np.argsort(content_similarities[item])[::-1] + + # 유저가 매긴 별점이 있는 유사한 여행지만 추출 + rated_similar_country = [] + for country in similar_countries: + if user_ratings[country] != 0: + rated_similar_country.append(country) + # 유사한 여행지 상위 5개로 제한 + if len(rated_similar_country) == 5: + break + + # 아무런 평가도 하지 않았을 경우 + if len(rated_similar_country) == 0: + # 무작위 5개 여행지에 대해 별점부여 + rnd_cnt = 5 + rated_similar_country = np.random.randint(0, len(country_name), rnd_cnt) + + for i in range(rnd_cnt): + c_index = rated_similar_country[i] + # 1.5~4.5 사이의 난수를 발생 + rnd_score = np.random.uniform(1.5, 4.5) + user_ratings[c_index] = rnd_score + + + weighted_ratings = 0 + similarity_sum = 0 + + # Hybrid Filtering + # 콘텐츠 기반 필터링이 적용된 상태 + for country in rated_similar_country: + similarity = content_similarities[item][country] + weighted_ratings += user_ratings[country] * similarity + similarity_sum += similarity + + predict_rating = weighted_ratings / similarity_sum + user_predicted_ratings[item] = predict_rating + + + + # 유저가 이미 평가한 여행지는 제외 + gone_coutries_index = np.where(user_ratings > 0) + user_predicted_ratings[gone_coutries_index] = 0 + + # print(user_ratings) + # print(user_predicted_ratings) + recommend_country_index = np.argsort(user_predicted_ratings)[::-1] + recommend_country_index = np.random.choice(recommend_country_index[:20], 10, replace=False) + recommend_country = country_name[recommend_country_index] + + print(recommend_country) + + db.close() + +def getCompanion(): db = Database() - res = db.select(''' - select c.id, c.name, cd.contents - from country_data as cd, country as c - where cd.id=c.id + + # Content기반 예측평점 계산 + with open('data.pickle', 'rb') as f: + content_similarities = pickle.load(f) + content_similarities = np.array(content_similarities) + + # {'test': 5.0, 'vory': 4.5} + country_id = 10 + user_id = 'vory' + sql = f''' + select c.user_id, c.id, ifnull(mr.rating, 0.0) as rating + from member_rating as mr right outer join (select member_info.id as user_id, country.* from member_info right outer join country on 1=1) as c + on mr.user_id=c.user_id and mr.country_id=c.id + order by 1, 2; + ''' + + df = pd.read_sql_query(sql, db.conn) + user_data = pd.pivot_table(df, index='user_id', columns='id', values='rating') + user_ratings = user_data.to_numpy() + user_index = user_data.index.get_loc(user_id) + coutry_list = user_data.columns.to_list() + + user_info = dict() + for u in user_data.index.to_list(): + # user_info는 이메일<->index값 + idx = user_data.index.get_loc(u) + user_info[idx] = u + user_info[u] = idx + + + + # STEP1. CF 필터링 (특정 여행지에 대해 유사한 상위 K개의 여행지를 추출 + K = 20 + similar_countries = np.argsort(content_similarities[country_id])[::-1] + similar_countries = similar_countries[:K] + + # 유저들의 예측 별점계산 + user_predicted_ratings = np.zeros((len(user_ratings), len(similar_countries))) + + + for idx, item in enumerate(similar_countries): + for user in range(len(user_ratings)): + # 이미 별점이 있는 경우 + if user_ratings[user][item] != 0: + user_predicted_ratings[user][idx] = user_ratings[user][item] + continue + + + # 유저가 별점을 매긴 여행지 & 특정여행지와 유사한나라의 교집합 + user_rated_index = np.nonzero(user_ratings[user])[0] + rated_similar_country = np.intersect1d(similar_countries, user_rated_index) + + + # 아무런 평가도 하지 않았을 경우 + if len(rated_similar_country) == 0: + # 무작위 5개 여행지에 대해 별점부여 + rnd_cnt = 5 + rated_similar_country = np.random.randint(0, len(similar_countries), rnd_cnt) + + for i in range(rnd_cnt): + c_index = rated_similar_country[i] + # 1.5~4.5 사이의 난수를 발생 + rnd_score = np.random.uniform(1, 5) + user_ratings[user][c_index] = rnd_score + + + weighted_ratings = 0 + similarity_sum = 0 + + # Hybrid Filtering + # 콘텐츠 기반 필터링이 적용된 상태 + for country in rated_similar_country: + similarity = content_similarities[country_id][country] + weighted_ratings += user_ratings[user][country] * similarity + similarity_sum += similarity + + predict_rating = weighted_ratings / similarity_sum + user_predicted_ratings[user][idx] = predict_rating + + + + # STEP2. 피어슨 유사도 + pearson_sim = np.zeros((len(user_ratings), len(user_ratings))) + for user in range(len(user_ratings)): + for other in range(len(user_ratings)): + if user == other: + pearson_sim[user][other] = 1.0 + continue + + u1_c = user_predicted_ratings[user] - np.mean(user_predicted_ratings[user]) + u2_c = user_predicted_ratings[other] - np.mean(user_predicted_ratings[other]) + + + denom = np.sqrt(np.sum(u1_c ** 2) * np.sum(u2_c ** 2)) + if denom != 0: + pearson_sim[user][other] = np.sum(u1_c * u2_c) / denom + else: + pearson_sim[user][other] = 0.0 + + + + # STEP3. 취향행렬 + # user_data: 취향정보 string 배열 + # user_data_gender: 혼성,동성 정보 배열 + + user_data_list = list() + user_data_gender = list() + sql = ''' + select mf.*, m.gender + from member_info as mf, member as m + where mf.id=m.email order by 1; - ''') + ''' + res = db.select(sql) + for idx, item in enumerate(res): + info = ";".join(item[1:4]) + user_data_list.append(info) + # 혼성/동성, 본인의 gender + user_data_gender.append([item[4], item[5]]) - country_word_list = [] - country_cnt = Counter([]) - country_name = [] - # ID, NAME, CONTENTS - for row in res: - country_name.append(row[1]) - crawl_data = eval(row[2]) - country_cnt += crawl_data - # '호텔': 665 -> 호텔 665번 나오게 됨 - word_list = list(Counter(crawl_data).elements()) - country_word_list.append(" ".join(word_list)) + vectorizer = TfidfVectorizer() + tfidf_matrix = vectorizer.fit_transform(user_data_list) + # 이미 행렬이 정규화된 상태이므로 linear_kernel + user_cosine_sim = linear_kernel(tfidf_matrix, tfidf_matrix) + user_cosine_sim = np.array(user_cosine_sim) + # print(user_cosine_sim) - country_name = np.array(country_name) - vectorizer = TfidfVectorizer() # 상위 500단어 추출 - tfidf_matrix = vectorizer.fit_transform(country_word_list) + # STEP3. 위 두개의 행렬계산 + # rating_distance는 평가하지 않은 사람들이 모두 동일하게 나오는 단점 + # 따라서 비율을 조정함(30%) + combine_scores = pearson_sim*0.3 + user_cosine_sim*0.7 - cosine_sim = linear_kernel(tfidf_matrix, tfidf_matrix) - cosine_sim = np.array(cosine_sim) + # input: user_id + companion_index = np.argsort(combine_scores[user_index])[::-1] + + # 동성/혼성 조건 + # 혼성으로 설정했으면 제외 + include_index = list() + for idx in companion_index[1:]: + if user_data_gender[user_index][0] == '혼성': + if user_data_gender[idx][0] == '혼성': + include_index.append(idx) + elif user_data_gender[user_index][1] == user_data_gender[idx][1]: # 성이 같을때만 + include_index.append(idx) + elif user_data_gender[user_index][0] == '동성': + if user_data_gender[user_index][1] == user_data_gender[idx][1]: # 성이 같을때만 + include_index.append(idx) + + + # 본인은 제외 (최대 10명까지) + max_len = min(10, len(include_index)) + include_index = include_index[:max_len] + companion_list = list() - # 사용자 별점정보 조회 - user_info = dict() # 이메일과 index 매칭 - user_data = dict() - res = db.select(''' - select user_id, country_id, rating - from member_rating - order by 1, 2; - ''') - # 'seo5220@naver.com': [0, 0, 0, 0, 0, 0, 0, 0, 0, + for idx in include_index: + companion_list.append(user_info[idx]) + + # 유저 정보 조회 + _str = ",".join('"'+x+'"' for x in companion_list) + sql = f''' + select email, name, phone_number, gender, birth, mbti, profile + from member + where email in ({_str}) + order by FIELD(email, {_str}); + ''' + res = db.select(sql) + + + result = list() for item in res: - index = item[0] - i_country = int(item[1]) - i_rating = int(item[2]) - if index not in user_data: - user_data[index] = np.array([0 for cn in country_name]) - user_data[index][i_country] = i_rating - # print(user_data) - - # 행=유저수, 열=Country수 - user_ratings = np.zeros((len(user_data.keys()), len(country_name))) - for idx, key in enumerate(user_data.keys()): - user_ratings[idx] = user_data[key] - # user_info는 이메일<->index값 - user_info[idx] = key - user_info[key] = idx - # print(user_info) + profile = item[6] + if profile != None: + profile = base64.b64encode(item[6]).decode('utf-8') - travel_cosim = cosine_similarity(user_ratings, cosine_sim) - _input = 'wlghddl9@naver.com' - _index = user_info[_input] - # 본인이 갔다왔던 여행지는 제외 - except_index = np.where(user_data[_input] > 0) - travel_cosim[_index][except_index] = 0 + info = { + 'user_id': item[0], + 'name': item[1], + 'phone': item[2], + 'gender': item[3], + 'birth': item[4], + 'mbti': item[5], + 'profile': profile + } + result.append(info) - score_indics = np.argsort(travel_cosim[_index])[::-1] - print(f"{country_name[score_indics]}") + # print(companion_list) + # print(result) + db.close() - db.close() \ No newline at end of file + + + + + + + + +if __name__ == "__main__": + # getCountry() + getCompanion() \ No newline at end of file diff --git a/README.md b/README.md index a0fe140..a88672b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +# 함께할래 ? +- Capstone Design Project for Kookmin University, 2023 +- [Check out the github-pages here](https://kookmin-sw.github.io/capstone-2023-14/) + + ### 1. 프로젝트 소개 팬데믹 당시 억눌려있었던 여행에 대한 갈망이 엔데믹 이후 증가하면서 많은 분들이 다시 여행을 가기 시작했습니다. 그러나 정작 본인이 가고픈 다음 여행지를 모를 때도 있을뿐더러, 나와 여행 기간, 예상 경비 등 조건이 맞는 지인을 찾기 힘들 때가 있습니다. diff --git a/backend/app.js b/backend/app.js index a253d22..c4df16e 100644 --- a/backend/app.js +++ b/backend/app.js @@ -1,17 +1,16 @@ -const express = require('express'); -const app = express(); +import express from 'express'; +import bodyParser from 'body-parser'; +import cookieParser from 'cookie-parser'; +import cors from 'cors'; +import routes from './routes/index.js'; -const bodyParser = require('body-parser'); -const cookieParser = require('cookie-parser'); -const routes = require('./routes'); -const cors = require('cors'); +const app = express(); const corsOptions = { origin: 'http://localhost:3000', credential: true, // 사용자 인증이 필요한 리소스(쿠키 ..등) 접근 }; - // Middleware app.use(cors(corsOptions)); app.use(express.json()); // JSON 형식의 요청 처리 diff --git a/backend/config/db.js b/backend/config/db.js index 772e600..7863e99 100644 --- a/backend/config/db.js +++ b/backend/config/db.js @@ -1,5 +1,6 @@ -const mysql = require('mysql'); -require('dotenv').config('../.env'); +import mysql from 'mysql'; +import dotenv from 'dotenv'; +dotenv.config('../.env'); const dbHost = process.env.DB_IP; const dbName = process.env.DB_NAME; @@ -23,4 +24,4 @@ db.connect((err) => { console.log('db 연결 성공'); }); -module.exports = db; +export default db; diff --git a/backend/controllers/auth.ctrl.js b/backend/controllers/auth.ctrl.js new file mode 100644 index 0000000..707759b --- /dev/null +++ b/backend/controllers/auth.ctrl.js @@ -0,0 +1,75 @@ +import bcrypt from 'bcrypt'; +import jsonwebtoken from 'jsonwebtoken'; +import db from '../config/db.js'; +import formidable from 'formidable'; + +const secretKey = process.env.HASH_SECRET_KEY; + +const login = (req, res) => { + const { email, password } = req.body; + + // mysql에서 사용자 정보 조회 + db.query('SELECT * FROM member WHERE email = ?', [email], (error, result) => { + if (error) throw error; + + // 조회된 사용자 정보 없을 때 + if (result.length === 0) { + res.status(401).json({ error: '등록되지 않은 사용자입니다.' }); + return; + } + + // 조회된 사용자 정보와 입력한 비밀번호를 비교 + const user = result[0]; + bcrypt.compare(password, user.passwd, (error, isMatch) => { + if (error) throw error; + + if (!isMatch) { + res + .status(401) + .json({ error: '사용자 이름 또는 패스워드가 올바르지 않습니다.' }); + } else { + const token = jsonwebtoken.sign({ id: user.id }, secretKey); + + res.cookie('token', token, { httpOnly: true }); + res.status(200).json({ success: true, userId: user.id, token: token }); + } + }); + }); +}; + +const signUp = (req, res) => { + const { email, passwd, name, phone, gender, birth, mbti, profile } = req.body; + // 이메일 중복 검사 + db.query('SELECT * FROM member WHERE email = ?', [email], (error, result) => { + if (error) throw error; + + if (result.length > 0) { + res.status(409).json({ error: '이미 존재하는 이메일입니다.' }); + return; + } + + bcrypt.hash(passwd, 10, (error, password_hash) => { + if (error) { + return res.status(500).json({ error: 'Server error' }); + } + + db.query( + 'INSERT INTO member (email, passwd, name, phone_number, gender, birth, mbti, profile) VALUES (?,?,?,?,?,?,?,?)', + [email, password_hash, name, phone, gender, birth, mbti, profile], + (error, result) => { + if (error) throw error; + res.status(201).json({ success: true }); + } + ); + }); + }); +}; + +const logout = (req, res) => { + // 쿠키에서 토큰 삭제 + res.clearCookie('token'); + + res.status(200).json({ success: true }); +}; + +export default { login, signUp, logout }; diff --git a/backend/controllers/board.ctrl.js b/backend/controllers/board.ctrl.js new file mode 100644 index 0000000..7d127ff --- /dev/null +++ b/backend/controllers/board.ctrl.js @@ -0,0 +1,64 @@ +import db from '../config/db.js'; + +const saveBoard = (req, res) => { + const { name, content, upload_time, update_time } = req.body; + const query = `INSERT INTO board (writer, content, upload_time, update_time) VALUES (?,?,?,?)`; + const values = [name, content, upload_time, update_time]; + + db.query(query, values, (error, result) => { + if (error) throw error; + res.status(201).json({ success: true }); + }); +}; + +const getBoardList = (req, res) => { + const query = `SELECT b.*, m.gender, m.birth, m.mbti FROM board as b, member as m where b.writer=m.name;`; + + db.query(query, (error, result) => { + if (error) throw error; + res.send(result); + }); +}; + +const saveReply = (req, res) => { + const { board_id, email, content, upload_time, update_time } = req.body; + const query = `INSERT INTO reply (board_id, replyer, content, upload_time, update_time) VALUES (?, ?, ?, ?, ? )`; + const values = [board_id, email, content, upload_time, update_time]; + + db.query(query, values, (error, result) => { + if (error) throw error; + res.status(201).json({ success: true }); + }); +}; + +const getReplyList = (req, res) => { + const { board_id } = req.body; + + const query = `SELECT * FROM reply WHERE board_id=?;`; + const values = [board_id]; + + db.query(query, values, (error, result) => { + if (error) throw error; + res.send(result); + }); +}; + +const getUserInfo = (req, res) => { + const { email } = req.body; + + const query = `SELECT name, gender, birth, mbti FROM member WHERE email=?;`; + const values = [email]; + + db.query(query, values, (error, result) => { + if (error) throw error; + res.send(result); + }); +}; + +export default { + saveBoard, + getBoardList, + saveReply, + getReplyList, + getUserInfo, +}; diff --git a/backend/controllers/openai.ctrl.js b/backend/controllers/openai.ctrl.js new file mode 100644 index 0000000..1ce3525 --- /dev/null +++ b/backend/controllers/openai.ctrl.js @@ -0,0 +1,18 @@ +import { Configuration, OpenAIApi } from 'openai'; + +const configuration = new Configuration({ + apiKey: process.env.API_SECRET_KEY, +}); +const openai = new OpenAIApi(configuration); + +const chat = async (req, res) => { + const { prompt } = req.body; + + const completion = await openai.createCompletion({ + model: 'text-davinci-002', + prompt: prompt, + }); + res.send(completion.data.choices[0].text); +}; + +export default chat; diff --git a/backend/controllers/recommend.ctrl.js b/backend/controllers/recommend.ctrl.js new file mode 100644 index 0000000..8a62461 --- /dev/null +++ b/backend/controllers/recommend.ctrl.js @@ -0,0 +1,54 @@ +import db from '../config/db.js'; + +const getFirstImage = (req, res) => { + const { cityList } = req.body; + + db.query( + `SELECT c.name, cd.* + FROM country_detail as cd, country as c + where cd.id=c.id and c.name in (?) order by field(c.name, ?);`, + [cityList, cityList], + (error, result) => { + if (error) throw error; + + const newRecommendList = result.map((city) => { + let buff = Buffer.from(city.picture1, 'binary'); + return { + ...city, + imgUrl: buff.toString('base64'), + }; + }); + res.send(newRecommendList); + } + ); +}; + +const getInfo = (req, res) => { + const { city } = req.body; + + db.query( + `SELECT c.name, cd.* + FROM country_detail as cd, country as c + where cd.id=c.id and c.name = ?`, + [city], + (error, result) => { + if (error) throw error; + + const info = result[0]; + let buff1 = Buffer.from(info.picture1, 'binary'); + let buff2 = Buffer.from(info.picture2, 'binary'); + let buff3 = Buffer.from(info.picture3, 'binary'); + + const infoDetail = { + ...info, + imgUrl1: buff1.toString('base64'), + imgUrl2: buff2.toString('base64'), + imgUrl3: buff3.toString('base64'), + }; + + res.send(infoDetail); + } + ); +}; + +export default { getFirstImage, getInfo }; diff --git a/backend/controllers/record.ctrl.js b/backend/controllers/record.ctrl.js new file mode 100644 index 0000000..f49b7ee --- /dev/null +++ b/backend/controllers/record.ctrl.js @@ -0,0 +1,89 @@ +import db from '../config/db.js'; + +const getCityList = (req, res) => { + db.query(`SELECT name FROM country ORDER BY name;`, (error, result) => { + if (error) throw error; + const cityList = result.map((city) => { + return city.name; + }); + res.send(cityList); + }); +}; + +const saveItinerary = (req, res) => { + const { + email, + destination, + rating, + duration_start, + duration_end, + cost, + record, + } = req.body; + + const valueList = [ + email, + destination, + rating, + duration_start, + duration_end, + record, + cost, + ]; + + db.query( + `INSERT INTO member_rating(user_id, country_id, rating, duration_start, duration_end, record, cost) + VALUES (?, (select id from country where name=?), ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + user_id=?, country_id=(select id from country where name=?), + rating=?, duration_start=?, duration_end=?, + record=?, cost=?;`, + [...valueList, ...valueList], + (error, result) => { + if (error) throw error; + res.status(201).json({ success: true }); + } + ); +}; + +const getItineraryList = async (req, res) => { + const { email } = req.body; + db.query( + `SELECT c.name as city_name, mr.country_id, mr.rating, mr.duration_start, mr.duration_end, mr.cost, mr.record, mr.picture1 as picture + FROM (select mr.*, cd.picture1 + from member_rating as mr, country_detail as cd + where mr.country_id=cd.id) as mr, country as c + WHERE user_id=? and mr.country_id = c.id;`, + [email], + (error, result) => { + if (error) throw error; + + const itineraryList = result.map((itinerary) => { + const buff = Buffer.from(itinerary.picture, 'binary'); + return { + ...itinerary, + imgUrl: buff.toString('base64'), + }; + }); + res.send(itineraryList); + } + ); +}; + +const removeItinerary = (req, res) => { + const { email, id } = req.body; + db.query( + `DELETE FROM member_rating WHERE user_id=? and country_id=?;`, + [email, id], + (error, result) => { + if (error) throw error; + } + ); +}; + +export default { + saveItinerary, + getCityList, + getItineraryList, + removeItinerary, +}; diff --git a/backend/controllers/user.ctrl.js b/backend/controllers/user.ctrl.js deleted file mode 100644 index e5d4854..0000000 --- a/backend/controllers/user.ctrl.js +++ /dev/null @@ -1,93 +0,0 @@ -const bcrypt = require('bcrypt'); -const jwt = require('jsonwebtoken'); -const db = require('../config/db'); -const formidable = require('formidable'); - -const secretKey = process.env.SECRET_KEY; - -const login = (req, res) => { - const { email, password } = req.body; - - // mysql에서 사용자 정보 조회 - db.query('SELECT * FROM member WHERE email = ?', [email], (error, result) => { - if (error) throw error; - - // 조회된 사용자 정보 없을 때 - if (result.length === 0) { - res.status(401).json({ error: '등록되지 않은 사용자입니다.' }); - return; - } - - // 조회된 사용자 정보와 입력한 비밀번호를 비교 - const user = result[0]; - bcrypt.compare(password, user.passwd, (error, isMatch) => { - if (error) throw error; - - if (!isMatch) { - res - .status(401) - .json({ error: '사용자 이름 또는 패스워드가 올바르지 않습니다.' }); - } else { - const token = jwt.sign({ id: user.id }, secretKey); - - res.cookie('token', token, { httpOnly: true }); - res.status(200).json({ success: true, userId: user.id }); - } - }); - }); -}; - -const signUp = (req, res) => { - const form = new formidable.IncomingForm(); - form.parse(req, (err, user) => { - if (err) throw err; - - // 이메일 중복 검사 - db.query( - 'SELECT * FROM member WHERE email = ?', - [user.email], - (error, result) => { - if (error) throw error; - - if (result.length > 0) { - res.status(409).json({ error: '이미 존재하는 이메일입니다.' }); - return; - } - - bcrypt.hash(user.password, 10, async (error, password_hash) => { - if (error) { - return res.status(500).json({ error: 'Server error' }); - } - - db.query( - 'INSERT INTO member (email, id, passwd, name, phone_number, gender, birth, mbti, profile) VALUES (?,?,?,?,?,?,?,?,FROM_BASE64(?))', - [ - user.email, - user.id, - password_hash, - user.name, - user.phone, - user.gender, - user.birth, - user.mbti, - user.profile, - ], - (error, result) => { - if (error) throw error; - res.status(201).json({ success: true }); - } - ); - }); - } - ); - }); -}; - -const logout = (req, res) => { - // 쿠키에서 토큰 삭제 - res.clearCookie('token'); - - res.status(200).json({ success: true }); -}; - -module.exports = { login, signUp, logout }; diff --git a/backend/controllers/users.ctrl.js b/backend/controllers/users.ctrl.js new file mode 100644 index 0000000..1185d5a --- /dev/null +++ b/backend/controllers/users.ctrl.js @@ -0,0 +1,40 @@ +import db from '../config/db.js'; + +const saveTaste = (req, res) => { + const { email, style, object, preferAge, preferGender } = req.body; + + const valueList = [email, style, object, preferAge, preferGender]; + + db.query( + `INSERT INTO member_info (id, style, object, prefer_age, prefer_gender) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE + id = ?, + style = ?, + object = ?, + prefer_age = ?, + prefer_gender = ?`, + [...valueList, ...valueList], + (error, result) => { + if (error) throw error; + res.status(201).json({ success: true }); + } + ); +}; + +const getUserInfo = (req, res) => { + const { email } = req.body; + + db.query( + `SELECT COUNT(m.cost) as totalCount, IFNULL(SUM(m.cost), 0) as totalCost, i.* + FROM member_rating AS m + RIGHT OUTER JOIN member_info AS i ON m.user_id = i.id + where i.id=?; + `, + [email], + (error, result) => { + if (error) throw error; + res.send(result); + } + ); +}; + +export default { saveTaste, getUserInfo }; diff --git a/backend/package-lock.json b/backend/package-lock.json index c8a610b..5c4ddff 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,7 +14,8 @@ "express": "^4.18.2", "formidable": "^2.1.1", "jsonwebtoken": "^9.0.0", - "mysql": "^2.18.1" + "mysql": "^2.18.1", + "openai": "^3.2.1" }, "devDependencies": { "eslint": "^8.36.0", @@ -1895,6 +1896,23 @@ "wrappy": "1" } }, + "node_modules/openai": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/openai/-/openai-3.2.1.tgz", + "integrity": "sha512-762C9BNlJPbjjlWZi4WYK9iM2tAVAv0uUp1UmI34vb0CN5T2mjB/qM6RYBmNKMh/dN9fC+bxqPwWJZUTWW052A==", + "dependencies": { + "axios": "^0.26.0", + "form-data": "^4.0.0" + } + }, + "node_modules/openai/node_modules/axios": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", + "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", + "dependencies": { + "follow-redirects": "^1.14.8" + } + }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -3982,6 +4000,25 @@ "wrappy": "1" } }, + "openai": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/openai/-/openai-3.2.1.tgz", + "integrity": "sha512-762C9BNlJPbjjlWZi4WYK9iM2tAVAv0uUp1UmI34vb0CN5T2mjB/qM6RYBmNKMh/dN9fC+bxqPwWJZUTWW052A==", + "requires": { + "axios": "^0.26.0", + "form-data": "^4.0.0" + }, + "dependencies": { + "axios": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", + "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", + "requires": { + "follow-redirects": "^1.14.8" + } + } + } + }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", diff --git a/backend/package.json b/backend/package.json index 846eb5b..a90a1ae 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,6 +2,7 @@ "scripts": { "start": "node app.js" }, + "type": "module", "dependencies": { "axios": "^1.3.4", "bcrypt": "^5.1.0", @@ -12,7 +13,8 @@ "express": "^4.18.2", "formidable": "^2.1.1", "jsonwebtoken": "^9.0.0", - "mysql": "^2.18.1" + "mysql": "^2.18.1", + "openai": "^3.2.1" }, "devDependencies": { "eslint": "^8.36.0", diff --git a/backend/routes/index.js b/backend/routes/index.js index 3c3221e..2fa1286 100644 --- a/backend/routes/index.js +++ b/backend/routes/index.js @@ -1,14 +1,34 @@ -const express = require('express'); +import express from 'express'; +import auth from '../controllers/auth.ctrl.js'; +import users from '../controllers/users.ctrl.js'; +import chat from '../controllers/openai.ctrl.js'; +import recommend from '../controllers/recommend.ctrl.js'; +import record from '../controllers/record.ctrl.js'; +import board from '../controllers/board.ctrl.js'; + const router = express.Router(); -const user = require('../controllers/user.ctrl'); +router.post('/api/login', auth.login); +router.post('/api/signup', auth.signUp); +router.post('/api/logout', auth.logout); + +router.post('/api/chat', chat); + +router.post('/api/get-image', recommend.getFirstImage); +router.post('/api/get-info', recommend.getInfo); + +router.post('/api/hashtag-taste', users.saveTaste); +router.post('/api/get-userInfo', users.getUserInfo); -router.get('/', (req, res) => { - res.send('Hello World!'); -}); +router.post('/api/record-write', record.saveItinerary); +router.get('/api/get-cityList', record.getCityList); +router.post('/api/get-recordList', record.getItineraryList); +router.post('/api/del-record', record.removeItinerary); -router.post('/api/login', user.login); -router.post('/api/signup', user.signUp); -router.post('/api/logout', user.logout); +router.post('/api/board-write', board.saveBoard); +router.get('/api/get-boardList', board.getBoardList); +router.post('/api/reply-write', board.saveReply); +router.post('/api/get-replyList', board.getReplyList); +router.post('/api/get-writerInfo', board.getUserInfo); -module.exports = router; +export default router; diff --git a/frontend/.gitignore b/frontend/.gitignore index 4d29575..1ffcc98 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -14,9 +14,9 @@ # misc .DS_Store .env.local -.env.development.local +.env.development .env.test.local -.env.production.local +.env.production npm-debug.log* yarn-debug.log* diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6ba05be..4579bfc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", - "axios": "^1.3.4", + "axios": "^1.3.5", "heic2any": "^0.0.3", "pako": "^2.1.0", "react": "^18.2.0", @@ -21,6 +21,8 @@ "react-router-dom": "^6.8.2", "react-scripts": "5.0.1", "react-slick": "^0.29.0", + "recoil": "^0.7.7", + "recoil-persist": "^4.2.0", "slick-carousel": "^1.8.1", "styled-components": "^5.3.9", "web-vitals": "^2.1.4" @@ -5117,9 +5119,9 @@ } }, "node_modules/axios": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.3.4.tgz", - "integrity": "sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==", + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.3.5.tgz", + "integrity": "sha512-glL/PvG/E+xCWwV8S6nCHcrfg1exGx7vxyUIivIA1iL7BIh6bePylCfVHwp6k13ao7SATxB6imau2kqY+I67kw==", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -6898,14 +6900,6 @@ "tslib": "^2.0.3" } }, - "node_modules/dotenv": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", - "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", - "engines": { - "node": ">=10" - } - }, "node_modules/dotenv-expand": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", @@ -8846,6 +8840,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/hamt_plus": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/hamt_plus/-/hamt_plus-1.0.2.tgz", + "integrity": "sha512-t2JXKaehnMb9paaYA7J0BX8QQAY8lwfQ9Gjf4pg/mk4krt+cmwmU652HOoWonf+7+EQV97ARPMhhVgU1ra2GhA==" + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -14812,6 +14811,14 @@ } } }, + "node_modules/react-scripts/node_modules/dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "engines": { + "node": ">=10" + } + }, "node_modules/react-slick": { "version": "0.29.0", "resolved": "https://registry.npmjs.org/react-slick/-/react-slick-0.29.0.tgz", @@ -14860,6 +14867,33 @@ "node": ">=8.10.0" } }, + "node_modules/recoil": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/recoil/-/recoil-0.7.7.tgz", + "integrity": "sha512-8Og5KPQW9LwC577Vc7Ug2P0vQshkv1y3zG3tSSkWMqkWSwHmE+by06L8JtnGocjW6gcCvfwB3YtrJG6/tWivNQ==", + "dependencies": { + "hamt_plus": "1.0.2" + }, + "peerDependencies": { + "react": ">=16.13.1" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/recoil-persist": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/recoil-persist/-/recoil-persist-4.2.0.tgz", + "integrity": "sha512-MHVfML9GxJP3RpkKR4F5rp7DtvzIvjWhowtMao/b7h2k4afMio/4sMAdUtltIrDaeVegH0Iga8Sx5XQ3oD7CzA==", + "peerDependencies": { + "recoil": "^0.7.2" + } + }, "node_modules/recursive-readdir": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index e11edf5..850f534 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", - "axios": "^1.3.4", + "axios": "^1.3.5", "heic2any": "^0.0.3", "pako": "^2.1.0", "react": "^18.2.0", @@ -16,6 +16,8 @@ "react-router-dom": "^6.8.2", "react-scripts": "5.0.1", "react-slick": "^0.29.0", + "recoil": "^0.7.7", + "recoil-persist": "^4.2.0", "slick-carousel": "^1.8.1", "styled-components": "^5.3.9", "web-vitals": "^2.1.4" diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..495561e --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/images/Common/emptyDefault.png b/frontend/public/images/Common/emptyDefault.png new file mode 100644 index 0000000..919fef1 Binary files /dev/null and b/frontend/public/images/Common/emptyDefault.png differ diff --git a/frontend/public/images/Common/feature.svg b/frontend/public/images/Common/feature.svg new file mode 100644 index 0000000..db1cae2 --- /dev/null +++ b/frontend/public/images/Common/feature.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/images/Footer/boardIcon.svg b/frontend/public/images/Footer/boardIcon.svg deleted file mode 100644 index c1d694a..0000000 --- a/frontend/public/images/Footer/boardIcon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/images/Footer/homeIcon.svg b/frontend/public/images/Footer/homeIcon.svg deleted file mode 100644 index a51dc8d..0000000 --- a/frontend/public/images/Footer/homeIcon.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/images/Footer/mypageIcon.svg b/frontend/public/images/Footer/mypageIcon.svg deleted file mode 100644 index 3121b77..0000000 --- a/frontend/public/images/Footer/mypageIcon.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/images/Footer/notiIcon.svg b/frontend/public/images/Footer/notiIcon.svg deleted file mode 100644 index e2c36e5..0000000 --- a/frontend/public/images/Footer/notiIcon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/images/Footer/recordIcon.svg b/frontend/public/images/Footer/recordIcon.svg deleted file mode 100644 index 4fc1bb1..0000000 --- a/frontend/public/images/Footer/recordIcon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/index.html b/frontend/public/index.html index aa069f2..8bff941 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -2,12 +2,12 @@ - + - React App + 함께할래? - 추천 알고리즘으로 원하는 여행지와 여행자를 동시에 diff --git a/frontend/src/App.js b/frontend/src/App.js index 923c864..7c471f7 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -9,6 +9,10 @@ import Board from './pages/Board/board'; import BoardContent from './pages/Board/boardContent'; import MyPage from './pages/MyPage/myPage'; import Setting from './pages/Setting/setting'; +import TasteSetting from './pages/Setting/tasteSetting'; +import ChatTest from './pages/ChatTest'; +import Info from './pages/Info/Info'; +import BoardUpload from './pages/Board/boardUpload'; function App() { return ( @@ -17,6 +21,7 @@ function App() { style={{ maxWidth: '480px', margin: '0 auto', + height: '100vh', minHeight: '100%', width: 'auto', position: 'relative', @@ -30,9 +35,13 @@ function App() { } /> } /> } /> + } /> } /> + } /> } /> } /> + } /> + } /> ); diff --git a/frontend/src/assets/Ball.gif b/frontend/src/assets/Ball.gif new file mode 100644 index 0000000..2b668eb Binary files /dev/null and b/frontend/src/assets/Ball.gif differ diff --git a/frontend/src/assets/Spinner1.gif b/frontend/src/assets/Spinner1.gif new file mode 100644 index 0000000..2f94ee6 Binary files /dev/null and b/frontend/src/assets/Spinner1.gif differ diff --git a/frontend/src/assets/Spinner2.gif b/frontend/src/assets/Spinner2.gif new file mode 100644 index 0000000..3631735 Binary files /dev/null and b/frontend/src/assets/Spinner2.gif differ diff --git a/frontend/src/components/Buttons/styles.js b/frontend/src/components/Buttons/styles.js index 0ad4159..fbda148 100644 --- a/frontend/src/components/Buttons/styles.js +++ b/frontend/src/components/Buttons/styles.js @@ -8,11 +8,17 @@ const DefaultSetting = styled.div` text-align: center; box-shadow: 5px 5px 15px rgba(0, 0, 0, 0.1); cursor: pointer; + + @media screen and (max-width: 400px) { + padding: 8px; + font-size: 18px; + } `; export const FullColor = styled(DefaultSetting)` background: #ef4e3e; color: #ffffff; + margin-bottom: 12px; `; export const StrokeColor = styled(DefaultSetting)` diff --git a/frontend/src/components/Destination/index.js b/frontend/src/components/Destination/index.js index 8185f0d..fe8574f 100644 --- a/frontend/src/components/Destination/index.js +++ b/frontend/src/components/Destination/index.js @@ -6,9 +6,7 @@ function Destination(props) { {props.title} - - 함께하면 좋은 '{props.companion}' 외 몇 명 - + {props.contents} ); } diff --git a/frontend/src/components/Fonts/fonts.js b/frontend/src/components/Fonts/fonts.js index 8af2f12..a24e4fe 100644 --- a/frontend/src/components/Fonts/fonts.js +++ b/frontend/src/components/Fonts/fonts.js @@ -3,14 +3,16 @@ import styled from 'styled-components'; const DefaultFontStyle = styled.div` margin: ${(props) => (props.margin ? props.margin : null)}; padding: ${(props) => (props.padding ? props.padding : null)}; + font-size: ${(props) => (props.size ? props.size : null)}; font-weight: ${(props) => (props.weight ? props.weight : null)}; text-align: ${(props) => (props.align ? props.align : null)}; cursor: ${(props) => (props.cursor ? props.cursor : null)}; color: ${(props) => (props.color ? props.color : null)}; + line-height: ${(props) => (props.line ? props.line : null)}; `; export const Title = styled(DefaultFontStyle)` - font-size: 18px; + font-size: ${(props) => (props.size ? props.size : '18px')}; font-weight: 700; `; diff --git a/frontend/src/components/Footer/boardIcon.svg b/frontend/src/components/Footer/boardIcon.svg new file mode 100644 index 0000000..fc06425 --- /dev/null +++ b/frontend/src/components/Footer/boardIcon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/components/Footer/findIcon.svg b/frontend/src/components/Footer/findIcon.svg new file mode 100644 index 0000000..77725df --- /dev/null +++ b/frontend/src/components/Footer/findIcon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/src/components/Footer/footer.js b/frontend/src/components/Footer/footer.js index df28da9..103e56b 100644 --- a/frontend/src/components/Footer/footer.js +++ b/frontend/src/components/Footer/footer.js @@ -1,31 +1,36 @@ import React from 'react'; -import { Wrap } from './styles'; +import { Wrap, Icon, FloatingButton } from './styles'; import { useNavigate } from 'react-router-dom'; +import { ReactComponent as Home } from './homeIcon.svg'; +import { ReactComponent as Record } from './recordIcon.svg'; +import { ReactComponent as Board } from './boardIcon.svg'; +import { ReactComponent as Info } from './findIcon.svg'; +import { ReactComponent as Mypage } from './mypageIcon.svg'; -const Footer = () => { +const Footer = (props) => { const navigator = useNavigate(); + const active = window.location.pathname.slice(1); + return ( - navigator('/home')} - /> - navigator('/record')} - /> - navigator('/board')} - /> - navigator('/')} - /> - navigator('/mypage')} - /> + + navigator('/home')} /> + + + navigator('/record')} /> + + + navigator('/board')} /> + + + navigator('/info')} /> + + + navigator('/mypage')} /> + + {active === 'board' || (active === 'record' && props.upload === false) ? ( + + + ) : null} ); }; diff --git a/frontend/src/components/Footer/homeIcon.svg b/frontend/src/components/Footer/homeIcon.svg new file mode 100644 index 0000000..7782d2b --- /dev/null +++ b/frontend/src/components/Footer/homeIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/Footer/mypageIcon.svg b/frontend/src/components/Footer/mypageIcon.svg new file mode 100644 index 0000000..5654b5a --- /dev/null +++ b/frontend/src/components/Footer/mypageIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/components/Footer/recordIcon.svg b/frontend/src/components/Footer/recordIcon.svg new file mode 100644 index 0000000..1e5222b --- /dev/null +++ b/frontend/src/components/Footer/recordIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/components/Footer/styles.js b/frontend/src/components/Footer/styles.js index bc30205..47c7693 100644 --- a/frontend/src/components/Footer/styles.js +++ b/frontend/src/components/Footer/styles.js @@ -12,8 +12,32 @@ export const Wrap = styled.div` align-items: center; justify-content: space-around; border-top: 1px solid #e9e9e9; +`; - > img { - cursor: pointer; +export const Icon = styled.div` + cursor: pointer; + path { + stroke: ${(props) => + props.value === props.active ? '#ED655E' : '#7c7c7c'}; + stroke-width: 2px; } `; + +export const FloatingButton = styled.div` + display: inline-block; + width: 52px; + height: 52px; + border-radius: 70%; + background-color: #ef4e3e; + text-align: center; + vertical-align: middle; + cursor: pointer; + font-size: 28px; + font-weight: bold; + color: #ffffff; + position: absolute; + right: 24px; + bottom: 80px; + line-height: 1.7em; + box-shadow: 5px 5px 15px rgba(0, 0, 0, 0.2); +`; diff --git a/frontend/src/components/Header/header.js b/frontend/src/components/Header/header.js index 217a266..6874bec 100644 --- a/frontend/src/components/Header/header.js +++ b/frontend/src/components/Header/header.js @@ -14,12 +14,19 @@ const Header = (props) => { /> {props.title} {props.title === 'record' ? ( - + ) : props.title === 'mypage' ? ( navigator('/setting')} /> + ) : props.title === 'board-upload' ? ( + + ) : props.title === 'taste' ? ( + ) : null} ); diff --git a/frontend/src/components/Header/styles.js b/frontend/src/components/Header/styles.js index 599137c..4f438cb 100644 --- a/frontend/src/components/Header/styles.js +++ b/frontend/src/components/Header/styles.js @@ -27,4 +27,16 @@ export const Wrap = styled.div` position: absolute; right: 20px; } + + > button { + all: unset; + position: absolute; + right: 20px; + cursor: pointer; + font-size: 16px; + border-radius: 20px; + background-color: #ef4e3e; + color: #ffffff; + padding: 2px 8px; + } `; diff --git a/frontend/src/components/Inputs/styles.js b/frontend/src/components/Inputs/styles.js index 8c42c46..bc7305d 100644 --- a/frontend/src/components/Inputs/styles.js +++ b/frontend/src/components/Inputs/styles.js @@ -21,11 +21,15 @@ export const InputWrap = styled.div` ? 'font-size: 14px; padding: 8px 12px;' : 'font-size: 18px; padding: 12px 15px;'} + @media screen and (max-width: 400px) { + ${(props) => (props.small ? null : 'padding: 8px 12px;')} + } + :hover, :focus-visible { border: ${(props) => (props.disabled ? null : '2px solid #ef4e3e')}; outline: none; - transition: 0.5s ease; + transition: 0.2s ease; } } `; diff --git a/frontend/src/components/Modals/recordDetail.js b/frontend/src/components/Modals/recordDetail.js index a862a84..22c02a2 100644 --- a/frontend/src/components/Modals/recordDetail.js +++ b/frontend/src/components/Modals/recordDetail.js @@ -6,56 +6,90 @@ import InputBox from '../Inputs/inputBox'; const RecordDetail = (props) => { // dimmed 영역 처리 const dimmed = useRef(); + const userRecord = props.record; + const handleModalOutsideClick = (event) => { if (props.detail && !dimmed.current.contains(event.target)) { props.setDetail(false); } }; + useEffect(() => { document.addEventListener('mousedown', handleModalOutsideClick); return () => { document.removeEventListener('mousedown', handleModalOutsideClick); }; }); + + const renderRatingStars = () => { + const stars = []; + for (let i = 1; i <= userRecord.rating; i++) { + stars.push( + , + ); + } + if (!Number.isInteger(userRecord.rating)) { + stars.push( + , + ); + } + for (let i = 1; i <= 5 - userRecord.rating; i++) { + stars.push( + , + ); + } + return stars; + }; return (
- 대한민국 서울 - - 2023.01.01-2023.03.24 - -
- - - - - -
+ 여행지 평점 +
{renderRatingStars()}
- - - + + +
나의 기록 -