개발 일지

키오스크 - DRF로 변환

만식 2024. 5. 28. 18:56

 

튜터님의 피드백을 반영하여, 키오스크만이라도 DRF로 변경

 

장점 :

API 구축의 간편함: DRF는 직관적인 API 구축 도구를 제공합니다. 이를 통해 HTTP 요청을 쉽게 처리하고 JSON 응답을 반환할 수 있습니다.

직렬화 기능: DRF는 데이터를 직렬화하고 역직렬화하는 도구를 제공하여, 데이터베이스의 쿼리 셋을 JSON과 같은 형태로 변환하고 반대로도 쉽게 처리할 수 있습니다.

인증 및 권한 관리: DRF는 다양한 인증 및 권한 부여 메커니즘을 제공하여, API의 보안성을 강화할 수 있습니다.

필터링, 검색 및 페이징: DRF는 내장된 필터링, 검색 및 페이징 기능을 제공하여, 데이터의 부분적 조회 및 관리를 용이하게 합니다.

유연성 및 확장성: DRF는 클래스 기반 뷰와 믹스인을 제공하여, 필요한 기능을 쉽게 확장하고 사용자 정의할 수 있습니다.

 

  • 진행 중 오류 : django.http.request.RawPostDataException

→ 일반적으로 Django가 요청의 본문(body)에 이미 접근한 후에 다시 접근하려고 할 때 발생한다. 이 오류는 주로 미들웨어, 뷰, 또는 다른 요청 처리 로직에서 요청 본문을 여러 번 읽으려 할 때 발생

해결방법 :

  1. 요청 본문을 한 번만 읽기
  2. @csrf_exempt 사용
  3. 커스텀 미들웨어 점검
document.getElementById('submitOrderBtn').addEventListener('click', function () {
    const selectedItemsArray = Object.entries(selectedItems).map(([name, item]) => {
        return {name: name, count: item.count};
    });

    const totalPrice = calculateTotalPrice(selectedItems);

    // 요청 데이터를 JSON 문자열로 변환하여 전송
    $.ajax({
        url: '/orders/get_menus/',
        cache: false,
        dataType: 'json',
        type: 'POST',
        contentType: 'application/json',
        data: JSON.stringify({items: selectedItemsArray, total_price: totalPrice}),
        beforeSend: function (xhr) {
            const csrfToken = getCsrfToken();
            if (csrfToken) {
                xhr.setRequestHeader('X-CSRFToken', csrfToken);
            } else {
                console.error('CSRF 토큰이 설정되지 않았습니다.');
                return false;
            }
        },
        success: function (data) {
            console.log('주문이 성공적으로 처리되었습니다.');
            window.location.href = '/orders/order_complete/' + data.order_number + '/';
        },
        error: function (error) {
            console.error('주문 처리 중 오류가 발생했습니다:', error);
        }
    });
});

 

@method_decorator(csrf_exempt)
    def post(self, request):
        try:
            # 요청의 본문을 한 번만 읽어서 사용
            data = request.data
            selected_items = data.get('items', [])
            total_price = data.get('total_price', 0)

            today = datetime.now().date()

            last_order = Order.objects.filter(created_at__date=today).order_by('-id').first()
            if last_order:
                order_number = last_order.order_number + 1
            else:
                order_number = 1

            new_order = Order.objects.create(
                order_number=order_number,
                order_menu=selected_items,
                total_price=total_price,
                status="A"
            )
            return JsonResponse({'order_number': new_order.order_number}, status=201)
        except json.JSONDecodeError:
            return JsonResponse({'error': 'Invalid JSON'}, status=400)

 

Category_text - 카테고리에 따른 메뉴변환

 

  • admin ID별로 가게 및 메뉴 카테고리 별로 각각의 변경이 가능하게 구현
  • ex) 치킨 / 카페 가게별로 불러오는 메뉴가 다르게 설정
########################  model  ########################

class User(AbstractUser):
    CATEGORY_CHOICES = (
        ("CH", "치킨"), # 한글로 수정
        ("CA", "카페"),
    )

    store_name = models.CharField(max_length=100)
    tel = models.CharField(max_length=100, unique=True)
    address = models.CharField(max_length=100)
    category = models.CharField(max_length=2, choices=CATEGORY_CHOICES)


########################  bot.py  ######################

def get_user_menu_and_hashtags(user):
    # 현재 로그인된 사용자의 메뉴 가져오기
    user_menu = Menu.objects.filter(store=user)
    # 메뉴에 연결된 해시태그 가져오기
    user_hashtags = Hashtag.objects.filter(menu_items__in=user_menu).distinct()

    # 메뉴 이름과 해시태그 문자열로 변환
    menu_list = [menu.food_name for menu in user_menu]
    hashtag_list = [hashtag.hashtag for hashtag in user_hashtags]

    return menu_list, hashtag_list


def bot(input_text, current_user):
    client = OpenAI(api_key=settings.OPEN_API_KEY)

    # 사용자의 카테고리 가져오기
    category = current_user.category

    # 사용자의 메뉴 및 해시태그 가져오기
    menu, hashtags = get_user_menu_and_hashtags(current_user)

    # 카테고리에 따라 시스템 지침 작성
    if category == "CH":
        category_text = "치킨"
    elif category == "CA":
        category_text = "카페"
    else:
        category_text = "음식점"

    system_instructions = f"""
        이제부터 너는 "{category_text} 직원"이야. 
        너는 고객의 말에 따라 메뉴를 추천해 줘야해 우리가게에는 {menu}가 있어.
        그리고 아래의 카테고리 중에서 고객의 질문과 관련이 있는 항목을 선택해줘: {hashtags}
        선택된 항목은 '선택된 항목: [항목]' 형식으로 반환하고,
        고객에게 전달할 메시지는 한 문장으로 서비스를 하는 직원처럼 '메세지: "내용"'으로 작성해줘.
    """

 

플로팅 - Javascript

 

  • 플로팅 메세지를 포함하는 Javascript 구현
  • 화면상에 플로팅으로 AI가 하는 말을 말풍선으로 표현하는 기능 추가

 

→ 구현 중 오류

 

        /* 플로팅 메시지 스타일 */
        #floatingMessage {
            position: fixed;
            bottom: 0;
            left: 0;
            width: 600px;
            padding: 20px;
            background-color: #f0f0f0;
            border-top-right-radius: 20px; /* 원하는 형태로 설정 */
            box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); /* 그림자 효과 */
            z-index: 999; /* 다른 요소 위에 나타나도록 설정 */
            
            
    <!-- 플로팅 메시지를 표시할 컨테이너 -->
    <div id="floatingMessage">
        <p id="transcription"></p>
    </div>
    
const startButton = document.getElementById('startButton');
const transcription = document.getElementById('transcription');
const floatingMessage = document.getElementById('floatingMessage');


function speak(text, callback) {
    transcription.textContent = text;
    const synth = window.speechSynthesis;
    const utterance = new SpeechSynthesisUtterance(text);
    utterance.lang = 'ko-KR';
    utterance.onend = function () {
        console.log("음성 안내가 끝났습니다.");
        if (callback) {
            callback();
        }
    };
    synth.speak(utterance);

}


function startSpeechRecognition() {
    if (!('webkitSpeechRecognition' in window)) {
        alert("음성 인식이 지원되지 않는 브라우저입니다.");
    } else {
        const recognition = new webkitSpeechRecognition();
        recognition.lang = 'ko-KR';
        recognition.start();
        recognition.onresult = function (event) {
            const transcript = event.results[0][0].transcript;
            const csrfToken = getCsrfToken();
            axios.post('/orders/aibot/', {inputText: transcript}, {
                headers: {
                    'X-CSRFToken': csrfToken
                }
            })
                .then(function (response) {
                    const responseText = response.data.responseText;
                    const hashtags = response.data.hashtags;
                    console.log('서버 응답:', responseText);
                    updateMenus(hashtags); // 메뉴 업데이트 먼저 실행
                    speak(responseText, startSpeechRecognition); // 그 다음에 음성 안내 실행
                })
                .catch(function (error) {
                    console.error('에러:', error);
                });
        };
        // recognition.onend = function () {
        //     startButton.textContent = '음성 입력 다시 시작';
        // };
    }
}

// transcription 요소에 변화가 있을 때 플로팅 메시지 업데이트
transcription.addEventListener('input', function () {
    const text = transcription.textContent.trim();
    if (text !== "") {
        // 플로팅 메시지 업데이트
        showFloatingMessage(text);
    }
});

let floatingMessageCount = 0; // 플로팅 메시지 카운트 변수

// 플로팅 메시지 표시 함수
function showFloatingMessage(message) {
    // 기존에 생성된 플로팅 메시지 요소 숨기기
    const oldFloatingMessages = document.querySelectorAll('.floating-message');
    oldFloatingMessages.forEach(function (element) {
        element.style.display = 'none';
    });

    // 새로운 플로팅 메시지 요소 생성
    const floatingMessage = document.createElement('div');
    const floatingMessageId = 'floatingMessage_' + floatingMessageCount; // 새로운 ID 생성
    floatingMessage.id = floatingMessageId;
    floatingMessage.className = 'floating-message'; // 클래스 추가
    floatingMessage.textContent = message;

    // 플로팅 메시지 컨테이너에 추가
    document.body.appendChild(floatingMessage);

    // 플로팅 메시지 표시
    floatingMessage.style.display = 'block';

    // 플로팅 메시지가 사라지도록 타이머 설정
    setTimeout(function () {
        const elementToRemove = document.getElementById(floatingMessageId);
        if (elementToRemove) {
            elementToRemove.remove();
        }
    }, 5000); // 5초 후에 플로팅 메시지 삭제

    // 카운트 증가
    floatingMessageCount++;
}

 

AI챗봇 - 음성인식 및 음성 챗봇

 

  • AI음성인식 기능이 포함된 키오스크에 더욱 편하게 볼 수 있도록 음성인식과 연동되는 챗봇을 표현
  • 저 연령층에게는 계속적인 음성인식은 불필요하다고 느낄 수 있다는 점에서, 버튼 형식의 on/off 음성인식 기능을 추가
  • 프롬프트를 좀 더 간결하고 저 연령층에 맞게 용건 전달식으로 변경

function startSpeechRecognition() {
    if (!('webkitSpeechRecognition' in window)) {
        alert("음성 인식이 지원되지 않는 브라우저입니다.");
    } else {
        const recognition = new webkitSpeechRecognition();
        recognition.lang = 'ko-KR';

        // 음성 인식 시작 시 스피너 표시
        recognition.onstart = function () {
            startButton.innerHTML = '<div style="display: flex; align-items: center;">' +
                '<span>음성입력중</span>' +
                '<span class="spinner-border" style="width: 3rem; height: 3rem; margin-left: 10px;" role="status">' +
                '<span class="visually-hidden"></span>' +
                '</span>' +
                '</div>';
        };

        recognition.onresult = function (event) {
            const transcript = event.results[0][0].transcript;
            const csrfToken = getCsrfToken();
            axios.post('/orders/aibot/', {inputText: transcript}, {
                headers: {
                    'X-CSRFToken': csrfToken
                }
            })
            .then(function (response) {
                const responseText = response.data.responseText;
                const hashtags = response.data.hashtags;
                console.log('서버 응답:', responseText);
                updateMenus(hashtags); // 메뉴 업데이트 먼저 실행
                speak(responseText);
            })
            .catch(function (error) {
                console.error('에러:', error);
            });
        };

        // 음성 인식 종료 시 버튼 텍스트 복구 및 클릭 이벤트 리스너 추가
        recognition.onend = function () {
            startButton.innerHTML = 'AI 음성인식';
            startButton.addEventListener('click', startSpeechRecognition);
        };

        recognition.start();
    }
}

 

템플릿 - 자바스크립트 메뉴 불러오기

  • 문제: 메뉴 페이지로 들어갔을 때 전체 메뉴가 불러와지지 않는 문제
  • 해결: js에서 작동할 때 해당 div의 id 값이 필요한데 html에 존재하지 않는 id 값을 호출하려고 했기 때문에 메뉴가 불러와지지 않았던 것

  • elder_start.html의 결과인 메뉴 필터링 결과를 elder_menu.html에서 반영하도록 코드 작성
  • elder_start에서 gpt response를 로컬 스토리지에 저장하는 방식으로 구현
  • elder_menu에서 필터링된 메뉴 개수에 따른 분기 처리(메뉴가 3개 이상, 3개 미만)
  • 각 메뉴가 팝업과 배경의 카드에 들어가도록 함

 

  1. 브라우저 이슈

-crome에서 elder_start.html의 안내 음성 자체가 플레이되지 않는 문제 발견

-safari, edge 등의 다른 브라우저는 일단 음성 안내 자체는 플레이 되는데, “음성인식”은 브라우저마다 약간의 차이를 보임 (일단 edge는 정상적으로 모두 기능함)

  1. 음성인식 시 inputText 이슈

-여러 번 테스트해보다 보니 어떤 때는 inputText가 잘 받아지는데, 어떤 때는 inputText가 올바르게 처리되지 않음을 발견함

-그 이유는 utterance멘트에 대한 speak()가 끝나고 난 후, 너무 빨리 메뉴에 대한 주문을 말하면 inputText로 제대로 잘 받아서 처리하지 못하는 듯함

-utterance멘트에 대한 speak()가 끝나고 난 후, 약 3~5초 후에 메뉴에 대한 주문을 말하면 음성인식이 잘 기능함

 

 

elder_start.html: 음성인식, gpt로부터 메뉴 필터링 결과를 받아옴

elder_menu.html: gpt의 메뉴 필터링 결과를 화면에 반영

결과적으로 elder_start의 gpt response를 어딘가에 저장해서 elder_menu에서 사용해야 함

저장은 쿠키에 할수도, 로컬 스토리지에 할 수도 있지만_쿠키는 웹 상에서만 사용된다는 한계가 존재하므로, 우리는 로컬 스토리지에 저장하는 방식을 선택함

 

##elder_start.html

    .then(function (response) {
        const responseText = response.data.responseText;
        const hashtags = response.data.hashtags;
        console.log('서버 응답:', responseText);
        localStorage.setItem('filteredHashtags', hashtags);
        localStorage.setItem('responseText', responseText);
        window.location.href = '/orders/elder_menu/';
        console.log("중요부분 타고 있니>>>>>>>>>>>>")
    })

 

## elder_menu.html

      document.addEventListener('DOMContentLoaded', function() {
          const filteredHashtags = localStorage.getItem('filteredHashtags');
          const responseText = localStorage.getItem('responseText');
          const popupOverlay = document.getElementById('popup-overlay');
          const popupImage = document.getElementById('popup-image');
          const popupName = document.getElementById('popup-name');
          const popupPrice = document.getElementById('popup-price');
          // const closePopup = document.getElementById('closePopup');
          console.log("filteredHashtags>>>>>>>>>>", filteredHashtags)
          console.log("responseText>>>>>>>>>>", responseText)

 

문제: 음성인식에 대한 해시태그와 그에 맞춰 필터링된 메뉴 결과를 저장하고 가져올 수 있도록 코드를 작성했음에도 불구하고 여전히 elder_menu.html에서는 데이터가 들어오지 않는 문제가 계속됨

해결: print문으로 찍어보니, current_user(staff계정)에 대한 정보가 없어서 오류가 발생하는 것으로 판단함

처음에 admin페이지에서 해당 staff계정으로 로그인해야 그가 작성한 해시태그와 메뉴에 대한 접근이 가능해짐

(로직상 최초 한번만 로그인해 두면 자동으로 staff계정에 대한 접근 가능)

admin페이지에서 staff계정으로 로그인한 뒤 elder_start, elder_menu로 이동해서 음성인식 과정을 진행해 보니 이제는 제대로 메뉴 데이터가 반영됨

 

elder_templates - 메뉴 갯수 제한 및 해시태그

 

문제: 음성인식으로 안내되는 메뉴와 팝업창에 뜨는 메뉴가 불일치하는 경우가 발생함을 확인

해결: 오류 당시에는 해당 해시태그에 대한 모든 메뉴가 조회되어 출력되는 등 팝업창과 그 배경의 카드에 들어갈 내용에 대한 구체적인 설정이 없어서 나타나는 현상으로 판단함

해시태그로 필터링한 메뉴의 갯수가 3개 이상이면 3개의 메뉴만 가져오고, 3개 미만이면 해당하는 메뉴만 가져오도록 코드 수정

그리고 해당 3가지의 메뉴 중 index상 첫 번째인 메뉴가 팝업에 표시되고, 나머지 2개의 메뉴(혹은 나머지 1개의 메뉴)는 카드에 표시되도록 수정

 

✅ “복숭아가 들어간 음료 추천해줘”에 대해서 정확성이 떨어지는 결과를 반환함

→ 확인해보니 복숭아 스무디와 복숭아 음료에 “복숭아”라는 해시태그가 없어서..

(결론) 메뉴 작성 시에는 최대한 해시태그 자세하게(원재료 포함)!

 

 ✅ 현재 팝업의 장바구니 기능의 구현이 완료되지 않은 상태임

→ 팝업을 끌 수 있게 “닫기”버튼을 js로 추가해 줌

 

 

음성인식 - 브라우저 오류 해결

  • 기존에 elder_start.html에서의 기본 안내 멘트 출력 및 사용자 음성인식이 chrome, safari 등의 특정 브라우저에서 기능하지 않는 문제가 있었음
    • chrome: 기본 안내 멘트 출력 x, 사용자 음성인식 x
    • safari: 튜터님들 mac에서는 둘 다 됨, 팀원 분 mac에서는 계속 사용자 음성인식 안됨
      • (노트북의 문제인지, 브라우저 문제인지를 확인하기 위해 튜터님들과도 test진행)
    • edge: window컴퓨터 사용 시에는 둘 다 됨, 팀원 분 mac에서는 사용자 음성인식 안됨
  • 해결: js코드로 음성인식을 위한 마이크 사용 여부를 묻는 걸로 추가함
    • 그 결과, 기본 안내 멘트 출력과 사용자 음성인식 모두 안되던 chrome에서 둘 다 가능해짐
        window.addEventListener('load', function () {
            console.log(">>>>>>>>>>>>> 실행");
            const welcomeMessage = "반갑습니다. 원하시는 메뉴를 추천해 드리겠습니다. 필요한 것이 있다면 말씀해주세요.";
            requestPermissions().then(() => {
                speak(welcomeMessage, startSpeechRecognition);
            }).catch(error => {
                console.error('권한 요청 실패:', error);
            });
        });

        function requestPermissions() {
            return new Promise((resolve, reject) => {
                navigator.mediaDevices.getUserMedia({ audio: true, video: false })
                    .then(stream => {
                        console.log('마이크 사용 권한이 허용되었습니다.');
                        resolve();
                    })
                    .catch(error => {
                        console.error('마이크 사용 권한을 얻지 못했습니다:', error);
                        reject(error);
                    });
            });
        }

 

elder_menu.html - 음성인식 재시작

  • elder_start에서 음성인식 이후 elder_menu로 넘어온 후에 필터 된 메뉴 모두가 마음에 들지 않을 경우에 다시 처음부터 주문 로직을 실행할 수 있는 버튼(음성인식 재시작 버튼)이 필요함
  • utterance에러
    • speak() 함수 자체를 타지 않는 문제→ 정상 작동하는 앞의 speak를 그대로 가져다가 text를 함수 내에서 재정의해서 사용했음
  • csrf토큰 에러
    • elder_start에서 사용한 csrf토큰을 elder_menu에서 가져오지 못하는 문제(Null으로 반환)
    • elder_start의 form태그를 그대로 사용해서 csrf토큰의 정보를 넘겨줘서 해결

elder_menu.html - 결제하기로 넘어가기

 

  • elder_menu.html에서 장바구니에 item들을 추가하고 결제하기 버튼을 누르면 에러가 발생했음
  • 기획에 따르면 “결제하기”버튼 클릭 시 “주문번호”가 나오는 화면으로 넘어가야 함
  • js코드로 html상의 id값인 totalPrice(←addItem()의 결과)으로 가져오고, 이를 currentTotal(←int로 바꿈)로 재정의함→ 이를 함수 정의 바깥에서 새롭게 정의하여 활용함으로써 문제 해결함
	
// 장바구니 안의 아이템 총합 구하는 함수
        function addItem(name, price) {
            // Logic to add the item to the cart
            let cartItems = document.getElementById('cartItems');
            let totalPrice = document.getElementById('totalPrice');
            let currentTotal = parseInt(totalPrice.textContent.replace('총 가격: ₩', ''), 10) || 0;

            cartItems.innerHTML += `<div class="cart-item">${name} - ₩${price}</div>`;
            currentTotal += price;
            totalPrice.textContent = `총 가격: ₩${currentTotal}`;
        }
        
// 결제하기 버튼을 눌렀을 때
document.getElementById('submitOrderBtn').addEventListener('click', function () {
        const selectedItemsArray = Object.entries(selectedItems).map(([name, item]) => {
            return {name: name, count: item.count};
        });

        // Retrieve the total price directly from the DOM //수정된 부분
        let totalPriceElement = document.getElementById('totalPrice');
        let currentTotal = parseInt(totalPriceElement.textContent.replace('총 가격: ₩', ''), 10) || 0;
    
        // 요청 데이터를 JSON 문자열로 변환하여 전송
        $.ajax({
            url: '/orders/get_menus/',
            cache: false,
            dataType: 'json',
            type: 'POST',
            contentType: 'application/json',
            data: JSON.stringify({items: selectedItemsArray, total_price: currentTotal}),
            beforeSend: function (xhr) {
                const csrfToken = getCsrfToken();
                if (csrfToken) {
                    xhr.setRequestHeader('X-CSRFToken', csrfToken);
                } else {
                    console.error('CSRF 토큰이 설정되지 않았습니다.');
                    return false;
                }
            },
            success: function (data) {
                console.log('주문이 성공적으로 처리되었습니다.');
                window.location.href = '/orders/order_complete/' + data.order_number + '/';
            },
            error: function (error) {
                console.error('주문 처리 중 오류가 발생했습니다:', error);
            }
        });
    });