세모튜브

小程序 앱만들기 - 2 : 관광정보 서비스 앱만들기(공공데이터이용) 본문

미니프로그램-小程序/위챗-관광정보 앱

小程序 앱만들기 - 2 : 관광정보 서비스 앱만들기(공공데이터이용)

iDevKim 2020. 4. 30. 15:24

유튜브강좌 : www.youtu.be/D2y870aeOeQ

github : https://github.com/semotube/korTourInfo

 

semotube/korTourInfo

Contribute to semotube/korTourInfo development by creating an account on GitHub.

github.com

 

 

tabBar 만들기

    - 홈 : 첫페이지 - 인기 행사, 최근 행사, 최신 숙박업체를 표시. 

    - 검색 : 서비스분류, 대분류, 중분류, 소분류등의 조건을 받아 결과를 표시 (매뉴얼 참조)

    - 나 : 개인정보, 분류코드 처리

 

- 페이지 생성

  "pages":[
    "pages/index/index",
    "pages/search/search",
    "pages/user/user",
    "pages/searchStay/searchStay",    
    "pages/logs/logs"
  ],

 

- tabBar 생성

  "tabBar": {
    "backgroundColor": "#fafafa",
    "borderStyle": "white",
    "selectedColor": "#AB956D",
    "color": "#666",
    "list": [
      {
        "pagePath": "pages/index/index",
        "iconPath": "resources/images/tabBar/home.png",
        "selectedIconPath": "resources/images/tabBar/home@sel.png",
        "text": "홈"
      },
      {
        "pagePath": "pages/search/search",
        "iconPath": "resources/images/tabBar/search.png",
        "selectedIconPath": "resources/images/tabBar/search@sel.png",
        "text": "검색"
      },
      {
        "pagePath": "pages/user/user",
        "iconPath": "resources/images/tabBar/user.png",
        "selectedIconPath": "resources/images/tabBar/user@sel.png",
        "text": "나"
      }
    ]
  },

- 사용되는 아이콘은 www.flaticon.com에 가입하면 무료 아이콘을 다운받을 수있으므로 적당하게 준비하면 된다. 

 

Flaticon, the largest database of free vector icons

Download all icons in SVG, PSD, PNG, EPS format or as webfonts

www.flaticon.com

필요한 아이콘 : 각각 선택되지않았을때와 선택 되었을때 2개의 아이콘이 필요하다.

아이콘을 다운받은 후 각각 아래의 이름으로  "resources/images/tabBar" 폴더에 저장한다.

    - 홈아이콘 : home.png, home@sel.png

    - 검색아이콘 : search.png, search@sel.png

    - 나아이콘 : user.png, user@sel.png

 

각 페이지 상단 네이게이션바 처리

<!--index.wxml-->
<cu-custom bgColor="bg-gradual-pink">
  <view slot="content">홈</view>
</cu-custom>


<!--search.wxml-->
<cu-custom bgColor="bg-gradual-pink">
  <view slot="content">검색</view>
</cu-custom>


<!--pages/user/user.wxml-->
<cu-custom bgColor="bg-gradual-pink">
  <view slot="content">나</view>
</cu-custom>

 

 

 

서버와 통신 Network 만들기

- OpenAPI 매뉴얼 다운로드 주소 : https://api.visitkorea.or.kr/openAPI/manual/manual.do

 

서비스 개요 및 호출방법 | OpenAPI 매뉴얼 | OpenAPI| TourAPI3.0

TourAPI 소개 한국관광공사는 국가정보자원의 개방 및 공유 정책에 부흥하여 아래와 같은 OpenAPI 서비스를 제공합니다. 약 7만여 건의 다국어 관광정보 제공 (국문, 영문, 일문, 중문간체, 중문번체, 독일어, 불어, 스페인어, 러시아어) 약 1만 4천여건의 관광용어 외국어 용례표기사전 정보제공 한국관광공사 포털 사이트인 VisitKorea(www.visitkorea.or.kr)와 동일한 최신의 관광정보 제공 앱, 웹서비스 대상의 Applicati

api.visitkorea.or.kr

- api.js 만들기

utils/api.js를 생성하고 여기에 서버와의 통신관련 소스를 처리하자.

 

- 공통 url만들기

먼저 매뉴얼의 "가)오퍼레이션 내용"의 형식을 살펴보면 아래의 구조로 되어있다.

http://api.visitkorea.or.kr/openapi/service/rest/KorService/오퍼레이션명 <= 아래 오퍼레이션 종류 참조

공통부분 주소를 아래와 같이 url에 기억시키고

키값도 key에 기억시키자.

// api.js
const util = require("./util.js");

const url = "http://api.visitkorea.or.kr/openapi/service/rest/KorService/";
const key = "Yf5NXdsSZLmRUKZwRF01ZF83ZEVX7GG%2B54cCW3KIHC6Vb7i8OXj2vJTZAqULvt5hH5fgUkARqzcd4YyIAVG54Q%3D%3D";

 

- utils/util.js에 showLoading(), hideLoading(), showModal() 컴포넌트 처리하기

const showLoading = msg => {
  wx.showLoading({
    title: msg,
    mask: true
  })
}
const hideLoading = () => {
  wx.hideLoading();
}
const showModal = (title, content, showCancel = true, mask = true, cancelText = "취소", confirmText ="확인") => {
  wx.showModal({
    title: title,
    content: content,
    showCancel: showCancel,
    mask: mask,
    cancelText: cancelText,
    confirmText: confirmText,
  })
}


module.exports = {
  formatTime: formatTime,
  showLoading,
  hideLoading,
  showModal,
}

 

 

- 공통 request만들기

에러코드는 아래 에러코드표 참조( 매뉴얼 참조 )

Promise :

- 자바스크립트 대표적 비동기식 http의 요청/응답 처리.

- 성공시 resolve()를 호출해서 처리

- 실패시 reject()를 호출해서 처리

function request(url, data = {}, method = "GET") {
  util.showLoading('조회중...');
  data.MobileOS = "ETC";
  data.MobileApp = "testApp";
  data._type = "json";
  return new Promise(function (resolve, reject) {
    wx.request({
      url: url,
      data: data,
      method: method,
      header: {
        'Content-Type': 'application/json'
      },
      success: function (res) {
        if (res.statusCode == 200) {
          
          let resultMsg = "정상";
          let resultCode = res.data.response.header.resultCode;
          switch (resultCode) {
            case  1: resultMsg = "어플리케이션 에러"; break;
            case  2: resultMsg = "데이터베이스 에러"; break;
            case  3: resultMsg = "데이터없음 에러"; break;
            case  4: resultMsg = "HTTP 에러"; break;
            case  5: resultMsg = "서비스 연결실패 에러"; break;
            case 10: resultMsg = "잘못된 요청 파라메터 에러"; break;
            case 11: resultMsg = "필수요청 파라메터가 없음"; break;
            case 12: resultMsg = "해당 오픈API서비스가 없거나 폐기됨"; break;
            case 20: resultMsg = "서비스 접근거부"; break;
            case 21: resultMsg = "일시적으로 사용할 수 없는 서비스 키"; break;
            case 22: resultMsg = "서비스 요청제한횟수 초과에러"; break;
            case 30: resultMsg = "등록되지 않은 서비스키"; break;
            case 31: resultMsg = "기한만료된 서비스키"; break;
            case 32: resultMsg = "등록되지 않은 IP"; break;
            case 33: resultMsg = "서명되지 않은 호출"; break;
            case 99: resultMsg = "기타에러"; break;
          }

          if (resultCode != 0) {
            util.hideLoading();
            util.showModal("ERROR", resultMsg, false);
            reject(res);
          } else {
            util.hideLoading();
            resolve(res.data);
          }
        } else {
          util.hideLoading();
          reject(res.errMsg);
        }
      },
      fail: function (err) {
        util.hideLoading();
        reject(err)
      }
    })
  });
}

에러코드

에러메세지

설명

00

NORMAL_CODE

정상

01

APPLICATION_ERROR

어플리케이션 에러

02

DB_ERROR

데이터베이스 에러

03

NODATA_ERROR

데이터없음 에러

04

HTTP_ERROR

HTTP 에러

05

SERVICETIMEOUT_ERROR

서비스 연결실패 에러

10

INVALID_REQUEST_PARAMETER_ERROR

잘못된 요청 파라메터 에러

11

NO_MANDATORY_REQUEST_PARAMETERS_ERROR

필수요청 파라메터가 없음

12

NO_OPENAPI_SERVICE_ERROR

해당 오픈API서비스가 없거나 폐기됨

20

SERVICE_ACCESS_DENIED_ERROR

서비스 접근거부

21

TEMPORARILY_DISABLE_THE_SERVICEKEY_ERROR

일시적으로 사용할 수 없는 서비스 키

22

LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR

서비스 요청제한횟수 초과에러

30

SERVICE_KEY_IS_NOT_REGISTERED_ERROR

등록되지 않은 서비스키

31

DEADLINE_HAS_EXPIRED_ERROR

기한만료된 서비스키

32

UNREGISTERED_IP_ERROR

등록되지 않은 IP

33

UNSIGNED_CALL_ERROR

서명되지 않은 호출

99

UNKNOWN_ERROR

기타에러

 

 

- 오퍼레이션 호출용 주소 만들기

오퍼레이션 종류는 오퍼레이션표 참조( 매뉴얼 참조 )

기본적인 구조는 "공통url + 오퍼레이션 + API 키값" 이다.

module.exports = {
  request,
  AreaCode:           url + "areaCode"          + "?serviceKey=" + key,//지역코드조회
  CategoryCode:       url + "categoryCode"      + "?serviceKey=" + key,//서비스 분류코드 조회
  AreaBasedList:      url + "areaBasedList"     + "?serviceKey=" + key,
  LocationBasedList:  url + "locationBasedList" + "?serviceKey=" + key,
  SearchKeyword:      url + "searchKeyword"     + "?serviceKey=" + key,
  SearchFestival:     url + "searchFestival"    + "?serviceKey=" + key,//행사정보 조회
  SearchStay:         url + "searchStay"        + "?serviceKey=" + key,//숙박정보 조회
  DetailCommon:       url + "detailCommon"      + "?serviceKey=" + key,
  DetailIntro:        url + "detailIntro"       + "?serviceKey=" + key,
  DetailInfo:         url + "detailInfo"        + "?serviceKey=" + key,
  DetailImage:        url + "detailImage"       + "?serviceKey=" + key,
}

번호

서비스명(국문)

오퍼레이션명(영문)

오퍼레이션명(국문)

1

국문 관광정보

서비스

areaCode

지역코드조회

2

categoryCode

서비스 분류코드 조회

3

areaBasedList

지역기반 관광정보 조회

4

locationBasedList

위치기반 관광정보 조회

5

searchKeyword

키워드 검색 조회

6

searchFestival

행사정보 조회

7

searchStay

숙박정보 조회

8

detailCommon

공통정보 조회 (상세정보1)

9

detailIntro

소개정보 조회 (상세정보2)

10

s

반복정보 조회 (상세정보3)

11

detailImage

이미지정보 조회 (상세정보4)

 

 

 

홈 소스 만들기 : index.js

공용 함수와 데이타 정의

//index.js
const api = require("../../utils/api.js");
const util = require("../../utils/util.js");
const app = getApp();

Page({
  data: {
    festivalList_Hot: [],//이미지 있는 조회순(인기순)
    festivalList_New: [],//수정일순(최신순)
    stayList_New: [],//수정일순(최신순)
  },

데이터를 가져오기 위해 API Network 함수 호출하기

  onLoad: function () {
    let toDay = util.getToday();
    this.searchFestival("P", toDay, 10);//이미지 있는 조회순(인기순)
    this.searchFestival("C", toDay, 6);//수정일순(최신순)
    this.searchStay("C", toDay, 6);//수정일순(최신순)
  },

오늘날자를 얻기위한 utils/util.js => getToday()작성

const getToday = () => {
  let date = new Date();//new Date('December 25, 1995 23:15:30');
  return [date.getFullYear(), date.getMonth() + 1, date.getDate()].map(formatNumber).join('');//join('-');
}


module.exports = {
  formatTime: formatTime,
  getToday: getToday,
  showToast,
  hideToast,
  showLoading,
  hideLoading,
  showModal,
}

API Network함수 작성

14) 행사정보 조회 오퍼레이션 명세 => 매뉴얼 참조

searchFestival( 파라메터 ) { ... }

    - eventStartDate : 행사 시작일 (형식 : YYYYMMDD)

    - arrange 파라메터 : 

        - (A=제목순, B=조회순, C=수정일순, D=생성일순)

        - 대표이미지가 반드시 있는 정렬

        - (O=제목순, P=조회순, Q=수정일순, R=생성일순)

    - numOfRows : 결과 수

  searchFestival(arrange = "", eventStartDate = "", numOfRows = 10) {
    let that = this;
    api.request(api.SearchFestival, {
      arrange: arrange,
      eventStartDate: eventStartDate,
      numOfRows: numOfRows,
    }).then(function (res) {
      if (res.response.header.resultMsg === "OK") {
        let item = res.response.body.items.item;
        if (!Array.isArray(item)) item = [item];
        // console.log("item : ", item)
        that.setData({
          festivalList_Hot: arrange === "P" ? item : that.data.festivalList_Hot,
          festivalList_New: arrange === "C" ? item : that.data.festivalList_New,
        })
        util.hideLoading();
      } else { console.error("resultMsg != 'OK' : ", res.response.header.resultMsg) }
    })
    .catch(function (res) {
      util.hideLoading();
      console.error("catch : ", res)
    })
  },

 

15) 숙박정보 조회 오퍼레이션 명세 => 매뉴얼 참조

searchStay( 파라메터 ){ ... }

    - eventStartDate : 행사 시작일 (형식 : YYYYMMDD)

    - arrange 파라메터 : 

        - (A=제목순, B=조회순, C=수정일순, D=생성일순)

        - 대표이미지가 반드시 있는 정렬

        - (O=제목순, P=조회순, Q=수정일순, R=생성일순)

    - numOfRows : 결과 수

  searchStay(arrange = "", eventStartDate = "", numOfRows = 10) {
    let that = this;
    api.request(api.SearchStay, {
      arrange: arrange,
      eventStartDate: eventStartDate,
      numOfRows: numOfRows,
    }).then(function (res) {
      if (res.response.header.resultMsg === "OK") {
        let item = res.response.body.items.item;
        if (!Array.isArray(item)) item = [item];
        // console.log("item : ", item)
        that.setData({
          stayList_Hot: arrange === "P" ? item : that.data.stayList_Hot,
          stayList_New: arrange === "C" ? item : that.data.stayList_New,
        })
        util.hideLoading();
      } else { console.error("resultMsg != 'OK' : ", res.response.header.resultMsg) }
    })
      .catch(function (res) {
        util.hideLoading();
        console.error("catch : ", res)
      })
  },

 

 

홈 레이아웃 만들기 : index.wxml

<!--index.wxml-->
<cu-custom bgColor="bg-gradual-pink">
  <view slot="content">홈</view>
</cu-custom>

<!-- Hot Festival swiper -->
<swiper class="screen-swiper round-dot" indicator-dots="true" circular="true" autoplay="true" interval="5000" duration="500">
  <swiper-item wx:for="{{festivalList_Hot}}" wx:key="*this">
    <image src="{{item.firstimage}}" mode="scaleToFill"></image>
  </swiper-item>
</swiper>


<!-- New Festival -->
<view class="cu-bar bg-white margin-top solids-bottom">
  <view class="action sub-title">
    <text class="text-xl text-bold text-green">행사정보</text>
    <text class="text-ABC text-green">Festival</text>
  </view>
  <view class='action'>
    <text class="cuIcon-more"></text>
  </view>
</view>
<view class="cu-list grid col-2">
  <view class="cu-item" wx:for="{{festivalList_New}}" wx:key="*this" bindtap="goDetail" data-idx="{{index}}">
    <view class="bg-pink padding radius text-center shadow-blur">
      <image src="{{item.firstimage2}}" mode="aspectFit" class="radius" style="height:300rpx"></image>
      <view class="text-cut margin-top-sm text-Abc">{{item.title}}</view>
    </view>
  </view>
</view>


<!-- New Stay -->
<view class="cu-bar bg-white margin-top solids-bottom">
  <view class="action sub-title">
    <text class="text-xl text-bold text-green">숙박정보</text>
    <text class="text-ABC text-green">STAY</text>
  </view>
  <view class='action'>
    <text class="cuIcon-more"></text>
  </view>
</view>
<view class="cu-list grid col-2">
  <view class="cu-item" wx:for="{{stayList_New}}" wx:key="*this" bindtap="goDetail" data-idx="{{index}}">
    <view class="bg-pink padding radius text-center shadow-blur">
      <image src="{{item.firstimage2}}" mode="aspectFit" class="radius" style="height:300rpx"></image>
      <view class="text-cut margin-top-sm text-Abc">{{item.title}}</view>
    </view>
  </view>
</view>
/**index.wxss**/
.cu-list.grid>.cu-item {
  position: relative;
  display: flex;
  padding: 20rpx;
  transition-duration: 0s;
  flex-direction: column
}

 

 

 

 

기타 소스 확인

실제 핸드폰 테스트시 wx.getMenuButtonBoundingClientRect()값이 전부 0이 자주 나옴.

0이 나오면 상단 네비게이션바 부분에 에러가 발생한다.

우선 아래 소스 추가하여 강제로 처리하자.

정상처리

//app.js

App({

  onLaunch: function () {

    // wx.getSystemInfo({
    //   success: e => {
    //     // console.log(e);
    //     this.globalData.StatusBar = e.statusBarHeight;
    //     let custom = wx.getMenuButtonBoundingClientRect();
    //     console.log('custom : ', custom);
    //     this.globalData.Custom = custom;
    //     this.globalData.CustomBar = custom.bottom + custom.top - e.statusBarHeight;
    //   }
    // });


    // 2.11.0버젼: getMenuButtonBoundingClientRect()값이 전부 0이 자주 나옴(핸드폰 테스트시)
    // 우선 아래 소스를 이용해서 처리.
    wx.getSystemInfo({
      success: e => {
        if (!wx.canIUse('getMenuButtonBoundingClientRect')) {
          wx.showModal({ title: 'Error', content: "can't use getMenuButtonBoundingClientRect()" })
        }

        let IS_ERROR = false;
        let custom = null;
        try {
          custom = wx.getMenuButtonBoundingClientRect();
          if (custom && custom.height) {
            if (custom.bottom == 0 || custom.top == 0) { IS_ERROR = true; }
            else { wx.setStorageSync("customInfo", custom) }
          } else { IS_ERROR = true; }
        } catch (e) { IS_ERROR = true; }

        let defaultCustom = { width: 87, height: 32, left: 317, top: 50, right: 404, bottom: 82 }
        if (IS_ERROR) { custom = wx.getStorageSync("customInfo") || defaultCustom }
        this.globalData.StatusBar = e.statusBarHeight;
        this.globalData.Custom = custom;
        this.globalData.CustomBar = custom.bottom + custom.top - e.statusBarHeight;
        this.globalData.BoxHeight = e.windowHeight - this.globalData.CustomBar;
        this.globalData.ISIPX = false;

        let x = e.model.toLowerCase().replace(/ /g, '');
        if (x.indexOf('iphonex') > -1 || x.indexOf('iphone1') > -1) {
          this.globalData.BoxHeight = this.globalData.BoxHeight - 34; //iphone x
          this.globalData.ISIPX = true;
        }
      },
    });

  },
  globalData: {
  }
})
/**app.wxss**/

@import "colorui/main.wxss";
@import "colorui/icon.wxss";