11
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

One JAPANハッカソンでNFCとLINE Payを利用したBashoToriサービスを作りました

Posted at

2018/5/26と6/16に行われた第2回One JAPANハッカソンにて、BashoToriというサービスを提案しました。
ここでは、BashoToriサービスで利用している技術と実装について説明していきます。

BashoToriとは

NFCタグおよびLINE Payと連携して、その場で手軽に場所や施設の予約・支払いができるアプリです。
使いたい場所に置いてあるNFCタグにスマホでタッチすると、LINEボットが予約フォームのURLを送ります。
全てLINEアプリとアプリ内ブラウザで完結するため、余計なアプリや面倒なログイン作業は不要という特徴があります。

第2回One JAPANハッカソンのテーマが「街づくり」だったので、公共施設や街の中のあらゆるスペースを簡単に貸し借りできるようなサービスがあればいいなー、ということで作ってみました。

構成図

BashoToriサービスの構成図は次のようになります。
bashotori_system_diagram.png
NFCタグからLINEボットとのトーク画面を呼び出し、ボットがLIFFアプリのURLを送ることで、アプリ内ブラウザで予約フォームを表示します(LIFFアプリについてはこちら)。
LINEのアプリ内ブラウザからLINE Payに遷移すると、オートログイン機能によりブラウザでIDとパスワードを入力することなくLINE Payの決済画面を表示することができます。

処理の流れ

それぞれの処理について見ていきましょう。

1. NFCタグからLINEアプリの起動

NFCタグから次のURLスキームに遷移すると、ウェブブラウザからLINEアプリを起動してボットとのトーク画面を呼び出すことができます。(参照) LINE URLスキームを使う
https://line.me/R/oaMessage/{LINE_id}/?{text_message}

今回は、NFCタグとしてアクアビットスパイラルズ社の「スマートプレート」を利用しました。
https://spirals.co.jp/ja/products/smartplate/

スマートプレートは各プレート(NFCタグ)にIDが割り当てられており、専用アプリでスマートプレートのサーバーに対して各プレートの遷移先コンテンツを設定することができます。
NFCでタッチするとスマートプレートのサーバー経由でコンテンツを表示するため、遷移先URLを追加・変更したいときにNFCタグそのものは手元になくてよいという特徴があります。
また、NFCが利用できない端末用にQRコードでコンテンツにアクセスすることもできます。
とにかくNFCタグをばらまいて、後からリモートで遷移先のコンテンツを設定することができるというわけです。

LINEのURLスキームでは?{text_message}でボットに送信するテキストメッセージを事前設定できるため、BashoToriサービスにおける「場所」を表すスマートプレートのIDを設定しておきます。
以上により、NFCタグからLINEボットとのトーク画面を開き、「場所」のIDをテキストメッセージに設定された状態にすることができました。
nfc_transition_resize.png

2. 予約フォーム(LIFFアプリ)の表示

ユーザーがテキストメッセージに設定されたIDを送信すると、LINE Messaging APIを利用して作成されたボットサーバーにWebhookが送られます。(参照) ボットを作成する

今回のボットはNode.jsをHerokuで動かしています。
送られたWebhookを受ける部分のコードは次のようになりました。

app.js
const express = require('express')
const bodyParser = require('body-parser')
const cors = require('cors')
const compression = require('compression')
const app = express()

// Importing LINE Messaging API SDK
const lineBot = require('@line/bot-sdk');
const botConfig = {
    channelAccessToken: process.env.LINE_BOT_ACCESS_TOKEN,
    channelSecret: process.env.LINE_BOT_CHANNEL_SECRET
}
const bot = new lineBot.Client(botConfig);

const placeIdMap = {
    // Mapping of place ID and place information
}
app.set('view engine', 'pug')
app.use(compression())
app.use(cors())

app.listen(process.env.PORT || 3000, function () {
    console.log("Express server listening on port %d in %s mode", this.address().port, app.settings.env);
});

/*
* API to receive webhook from LINE server
*/
app.post('/webhook', lineBot.middleware(botConfig), (req, res) => {
    res.sendStatus(200);
    req.body.events.map((event) => {
        // We skip connection validation message.
        if (event.replyToken == "00000000000000000000000000000000"
            || event.replyToken == "ffffffffffffffffffffffffffffffff") return;
        if (event.type == 'message') {
            let message = {
                type: 'text'
            };
            if (event.message.text.startsWith('liff:')) {
                const placeId = event.message.text.slice(5);
                if (placeIdMap[placeId]) {
                    message.text = `line://app/{LIFF_APP_ID}?placeId=${event.message.text.slice(5)}`;
                } else {
                    message.text = '不正な場所IDです。';
                }
            } else {
                message.text = 'すみません、わかりませんでした。';
            }
            return bot.replyMessage(event.replyToken, message).then((response) => {
                console.log(response);
            }).catch((err) => {
                console.log(err);
            });
        };
    });
});

liff:{場所ID}というテキストメッセージが送信されたら、そこから場所IDを取得して予約フォーム用のLIFFアプリのURLを返します。
なお、あくまでプロトタイプなのでこのような設計となっていますが、正式運用時にはユーザーが場所IDを送信しなくてもよい仕組みを用意する必要があるでしょう。
liff_url_resize.png

ユーザーがURLを踏むと、アプリ内ブラウザでLIFFアプリが表示されます。
今回は、Amazon S3のStatic Website HostingでLIFFアプリを配信しています。
calendar_resize.png

LIFFアプリは通常のフロントエンド実装と変わりませんが、LIFF SDKを利用することで、LINEアプリのユーザーIDなどを取得することができます。

liff.js
window.onload = function (e) {
    liff.init(function (data) {
        const userId = data.context.userId;
        // Other initialization
    });
};

注意事項:

上記Webhookを受けるAPIでは、POSTボディ(JSON)のパーサーであるbody-parserを利用していません。
これはLINE Bot SDKが内部的にbody-parserを利用しており、ボットサーバー側でも利用するとコンフリクトを起こすからです。
(参照) https://line.github.io/line-bot-sdk-nodejs/guide/webhook.html#do-not-use-another-body-parser-before-the-webhook-middleware
よく見るとbody-parser自体はrequireしていますね。後で使います。

3. LINE Pay画面の表示

LIFFアプリで予約したい日時を選択すると、LINE Pay APIから決済URLとトランザクションIDを取得し、LINE Pay画面に遷移して支払い処理を行います。(参照) LINE Pay APIを使ってアプリに決済を組み込む方法
先述した通りLINE Pay画面をアプリ内ブラウザで表示することで、LINEアカウントでの自動ログイン処理が走ります。

ただ、ここで1つ問題が発生しました。
上記参照ページに記載されている通り、LINE Pay APIを呼び出すには呼び出し元IPをLINE Pay側に登録する必要があります。つまり、呼び出し元は固定Global IPアドレスを持たなければなりません。
しかしボットサーバーが動いているのはHeroku、IPアドレスは頻繁に変わります。LINE PayのIP登録画面には「Mask値は24〜30の整数で入力してください。」とあるため、Herokuサーバーの取り得るIPアドレスを全て登録するのも現実的ではありません。

そこで、HerokuのアドオンであるFixieを使うことにしました。
FixieはHerokuインスタンスから固定IPでHTTP/HTTPSリクエストを送ることができるプロキシで、Node.jsのrequestモジュールを使うのであれば、次のように設定できます。

const request = require('request')
const fixieRequest = request.defaults({'proxy': process.env.FIXIE_URL});

fixieRequest('http://www.example.com', (err, res, body) => {
  console.log(`Got response: ${res.statusCode}`);
});

(参照) https://devcenter.heroku.com/articles/fixie#using-with-node

ただ、更にここで問題が。
今回はLINE Pay SDKを使っており、実際にLINE Pay APIにアクセスするのはこのSDKであるため、そこにFixieプロキシを通す必要があります。
LINE_PAY_HOSTNAME環境変数にプロキシホスト名を設定すればOKとのことですが、これにFixieのURLをセットしても上手く動きませんでした。プロキシ認証が必要だからでしょうか。。?

仕方ないので、LINE Pay SDKを改変することにしました。
line-pay.jsでrequestモジュールを読み込んでいる箇所で上記のようにFixieプロキシを通すコードを追加し、見事LINE Pay APIを呼び出して決済URLを取得することができました。
line_pay_resize.png

ユーザーがLINE Payでの支払いに同意すると、決済URL取得時に送付したURLへリクエストが飛んでくるので、その際に必要なトランザクション情報を予め永続化しておきます。今回はHerokuアドオンのRedisを利用しました。

なお、決済URLを取得するAPIではPOSTボディ(JSON)をパースする必要がありますが、ここはLINEサーバーからのWebhook(Bot呼び出し)ではなくLIFFアプリから呼び出されるので、次のように自前でbody-parserを利用しています。

app.js
const linePay = require('./line-pay');
const pay = new linePay({
    channelId: process.env.LINE_PAY_CHANNEL_ID,
    channelSecret: process.env.LINE_PAY_CHANNEL_SECRET,
    isSandbox: true
});
const bodyParser = require('body-parser')
app.use('/reservations', bodyParser.json())
app.use('/reservations', bodyParser.urlencoded({ extended: true }))

/*
* Start reservation processing.
* @returns {object} 
*/
app.post("/reservations", (req, res) => {
    // Call LINE Pay API via SDK to acquire payment URL
    const productInfo = req.body.productInfo;
    let transaction = {
        productName: productInfo.name,
        amount: productInfo.price,
        currency: 'JPY',
        confirmUrl: process.env.LINE_PAY_CONFIRM_URL,
        confirmUrlType: 'SERVER',
        orderId: `${req.body.userId}-${Date.now()}`
    }
    pay.reserve(transaction)
        .then((response) => {
            // Persist transaction information used for webhook processing from LINE Pay server
            // 中略

            // Success response
            res.status(200).send({ "result": "success", "uri": response.info.paymentUrl.web });
        })
        .catch((err) => {
            console.log('Error occurred: ' + err);
            // Error response
            res.status(400).send({ "result": "error", "message": "Reservation failed." });
        });
}

注意事項:

LINE Pay SDK自体はMITライセンスなので改変OKですが、LINEの中の人に怒られるので正式運用するサービスではちゃんと固定IPサーバーを用意するようにしましょう(自戒)。

4. 決済処理

ユーザーがLINE Payでの支払いに同意すると、決済URL取得時にLINE Pay APIへ渡したconfirmUrlに対してGETリクエストが送出されます。

appjs
/*
* If user approves the payment, LINE Pay server calls this API.
*/
app.get("/pay/confirm", (req, res) => {
    const transactionId = req.query.transactionId;
    // Get transaction from DB
    // Persist reservation 
    // 中略

    // Call LINE Pay API to confirm the payment
    let confirmation = {
        transactionId: transactionId,
        amount: transaction.amount,
        currency: transaction.currency
    }
    return pay.confirm(confirmation).then((response) => {
        console.log(response);
        res.sendStatus(200);
        let messages = [{
            type: "sticker",
            packageId: 2,
            stickerId: 144
        }, {
            type: "text",
            text: `おめでとうございます! ${transaction.productName} を予約しました。`
        }]
            return bot.pushMessage(transaction.userId, messages);
        }).catch((err) => {
           console.log(err);
           res.sendStatus(400);
           return _pushInternalErrorMessage(transaction.userId);
       });
}

クエリパラメータでトランザクションIDが送られてくるので、それを基に予約情報を生成・永続化し、LINE Payの決済実行APIを呼び出すと決済完了となります。
※実際はLINE Payで決済エラーとなった際のロールバック処理が必要です。
reserved_resize.png

DB設計

Redisの呼び出し部分の実装は長くなるため省略しました。
Redisは今まで使ったことがなかったので、手探りで次のようなキーを持つテーブルを設計しています。

  • transaction:{transacation_id} - トランザクション情報 (LINE Pay経由処理用)
  • 600sec程度で自動削除されるように設定
  • reservation:{reservation_id} - 予約情報 (IDはひとまずtransacation_idと同じ)
  • user:{user_id}:reservations - ユーザー単位の予約情報
  • place:{place_id}:reservations - 場所単位の予約情報

userとplaceは予約の度にsaddでアトミックに追加し、valueはreservation_id(の集合)としています。
もっと良いやり方がありそうな気もしますが、とりあえずこれで。
(参照) https://stackoverflow.com/questions/10907942/how-to-have-relations-many-to-many-in-redis

試行錯誤

最初はボットサーバーとしてHerokuではなくAWS Lambdaを利用していましたが、上記の固定IPアドレス問題に遭遇し、Heroku+Fixie(+SDK改変)という構成に変更しました。
Lambdaを固定IPで運用するためには、NATインスタンスを用意してLambdaをVPC内に入れる必要があります。
(参照) Amazon VPC 内のリソースにアクセスできるように Lambda 関数を構成する

しかしLambdaをVPCで運用すると、コールドスタート時に毎回ENIが作成されるなど性能面でのオーバーヘッドが大きいため、採用を見送ったという経緯があります。お金もかかるし。
(参照) 全部教えます!サーバレスアプリのアンチパターンとチューニング]※PDF

ハッカソンの短い期間でこの辺の判断をしなくてはならないので、なかなか大変でした。

改善項目

だいぶ突貫で作った感があるため、いろいろ改善したい項目があります。

  • LIFFアプリ表示シーケンス
    • 現在はボットとのトーク画面を開いた際のテキスト入力欄に場所IDを設定していますが、ユーザーがそのまま送信してくれるとは限りませんし、そもそもNFCタグを経由しなくても手で入力できてしまいます。
    • NFCタグからLIFFアプリ(https://line.me/R/app/~)を表示すれば、実はボットが不要になります。
      • この場合、通常のWebサイトに遷移するのとあまり変わりませんが、LINEのアプリ内ブラウザから表示するのでLINE Payに自動ログインできるという利点があります。
      • とはいえボット自体は予約確認など他の用途にも使えそうなので、ボットとやり取りするシーケンスは残しておきたいところです。
    • 今考えているのは、NFCからユーザーID取得用のLIFFアプリへ遷移し、ボットのトーク画面にリダイレクトすると同時にボットサーバーへユーザーIDを送付してPush APIでURLを送ることです。
      • これができればボットを利用しつつ、サーバー起点で予約フォームのURLを送ることができます。
  • 予約フォームURLへの一時トークン付与
    • 予約フォームのURLは有効期限がないので、一度NFCタグから取得すれば後で再利用が可能です。
    • BashoToriのコンセプト上「その場で予約する」というのが重要なので、期限付きトークンをURLに付与することも検討しています。
  • Androidキャッシュ問題の対策
    • AndroidのLINEアプリはアプリ内ブラウザで表示したHTMLをキャッシュするため、LIFFアプリを更新してもすぐには反映されません。HTMLのmetaタグにキャッシュ制御を入れても効果はないようでした。

      index.html
      <meta http-equiv="Pragma" content="no-cache">
      <meta http-equiv="Cache-Control" content="no-cache, no-store">
      
    • Androidの 設定 > アプリ > LINE > ストレージ > キャッシュを削除 で削除できますが、UX的にはもうちょっとマシな方法を考えたいところです。

  • 決済完了後のUI遷移
    • 決済が完了するとボット画面に完了通知を送っていますが、LIFFアプリ自体は開いたままです。
    • LIFF SDKのcloseWindow()でLIFFアプリを閉じることができますが、iOSとAndroidでは挙動が異なるようで上手く動かなかったので、そのままにしています。以下は推測。
      • iOS: アプリ内ブラウザで開いた画面のうち、今表示されているものを閉じる
        • LINE Pay画面が表示されていると閉じてしまう
      • Android: LIFFアプリ画面を閉じ、そこから遷移した画面は閉じない
        • LINE Pay画面が表示されていても閉じず、裏のLIFF画面だけが閉じる
    • LINE Payの決済完了通知はボットサーバーの方に来るので、それをLIFFアプリ側で検知するにはトランザクションの状況をポーリングするなどの処理が必要になります。

まとめ

NFCタグからLIFFアプリ起動、LINE Payでの支払いまでシームレスな操作で場所の予約ができるサービスを作りました。
HerokuやS3 Static Website HostingなどのPaaS/サーバーレスで運用しているため、メンテが楽でお金もかからない環境で稼働しています。
もっとも正式運用するような場合はそれ相応の環境が必要になりますが、価値検証用のプロトタイピングであれば十分な環境がほぼ無料で簡単に使えるというのは有り難いことですね。

One JAPANとは

日本の若き力を集め、collective impact (立場の異なる組織が、壁を越えてコラボレーションし、社会的課題を解決すること) の効果で日本の発展をドライブしようという有志団体です。
働き方、イノベーション、SDGs など、現代社会が抱えるさまざまな問題について、企業横断的に課題解決に取り組んでおり、現在、約50社が参画しています。
その活動のひとつであるOne JAPANハッカソンでは、One JAPAN 参画企業が持つ技術、リソース、人材を広く開放することで、日本のイノベーションにつながるような大きなアイデアが生まれることを期待しています。

[参考URL]https://onejapan.jp/

11
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?