Vue.jsで高校の文化祭投票システムを作った話

# Vue.jsで高校の文化祭投票システムを作った話

この記事はVue#2アドベントカレンダーの25日目です🎅

## はじめに

### 目次

H2タグだけまとめた見出しです

| 番号 | 目次                                                      |
| ---- | --------------------------------------------------------- |
| 1    | [はじめに](#はじめに)                                     |
| 2    | [利用したAWSのサービス](#利用したAWSのサービス)           |
| 3    | [フロント部分について](#フロント部分について)             |
| 4    | [バックエンド部分について](#バックエンド部分について)     |
| 5    | [画面遷移時の処理](#画面遷移時の処理)                     |
| 6    | [不正投票対策](#不正投票対策)                             |
| 7    | [投票処理](#投票処理)                                     |
| 8    | [投票完了処理](#投票完了処理)                             |
| 9    | [PC用投票ページ](#PC用投票ページ)                         |
| 10   | [つまずいた点など](#つまずいた点など)                     |
| 11   | [小ネタ](#小ネタ)                                         |
| 12   | [反省点](#反省点)                                         |
| 13   | [ソースコード（GitHub）](#ソースコードgithub)             |
| 14   | [参考記事（覚えている範囲で）](#参考記事覚えている範囲で) |
| 15   | [終わりに](#終わりに)                                     |

### 注意事項

できるだけ正確な記事を書こうと心がけておりますが、至らない点があるかもしれません。ご了承ください。
また、そのような間違いを発見された場合は、TwitterのDMまたはコメント欄でご指摘頂けますと大変助かります。

### 記事の概要

Vue.jsを使って高校の文化祭大賞投票システムを作ったので、後輩への引き継ぎ資料がてら記事を書いてみました。

### デモ用ページ

以下のページがデモ用のページとなっており、実際の挙動を確認できます。
<https://vote-test.shinbunbun.info/>
※スマホからしかアクセスできません。PCからアクセスする場合はChromeのデベロッパーツール等を使ってください。

ソースコードは[こちら](#ソースコードgithub)です。

### 去年のシステム

去年はLINEBotを使いました。（詳しくは[こちら](https://qiita.com/shinbunbun_/items/af55e35888c441a5b878)）

上の記事にも書いている通り、失敗ではないもののなかなか上手く行かない点がいくつかありました。

### 今年のシステム

去年の反省を踏まえ、今年は以下のようにWebで投票ができるシステムにしました。
一般出展団体に3票、飲食出展団体に1票投票できます。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Vue.jsで高校の文化祭投票システムを作りましたー！ <a href="https://t.co/qHZ9t8jo9M">pic.twitter.com/qHZ9t8jo9M</a></p>&mdash; しんぶんぶん@時間割bot (@shinbunbun_) <a href="https://twitter.com/shinbunbun_/status/1179361752951250944?ref_src=twsrc%5Etfw">October 2, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

### 制作スケジュール

元々Webで作るという構想が浮かんだのは6月あたりです。ちなみに当時はVue.jsどころかフロント自体ほとんど書いたことがない状態でしたが、文化祭本番は10月末だったので、7月中にパンフレットを完成させて8月からVue.jsを勉強しつつ制作に着手すれば全然間に合いそうだなーとか舐めたことを考えていました。

ですが、パンフレット制作が9月に食い込んでしまい、Vue.jsの勉強すら未着手のまま本番2週間前に突入するという最悪の事態に...。

とりあえずVue.jsを学ばなくてはと思い、行き帰りの電車や休み時間などをフル活用して1週間で猫本を読了し、本番1週間前から制作をはじめました。2日くらい学校へ行くのを忘れていたような気もしますが、総作業時間50時間でどうにか完成させました。

### アーキテクチャ

フロント部分はVue.jsで書いてS3に静的ホスティング、バックエンドはNode.jsで書いてLambdaで動かし、DBはDynamoDBを使用しました。
S3の静的ホスティングだけではTLS通信ができないため、CloudFrontとACMをを使用しました。（詳細は[CloudFront](#cloudfront)と[ACM](#acmaws-certificate-manager)）

![Untitled (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260292/d7ab1c36-a573-ea28-aba2-20301820d329.png)

## 利用したAWSのサービス

### API Gateway

<https://aws.amazon.com/jp/api-gateway/>

簡単にAPIの作成ができるサービスです。Lambda関数をAPI経由で叩く際必要になります。

### Lambda

<https://aws.amazon.com/jp/lambda/>

イベント発生時に任意のコードを実行することができるFaaSです。今回はAPI Gatewayと連携させて、APIが叩かれたタイミングで関数を実行するといった風に使用しました。

### CloudFront

<https://aws.amazon.com/jp/cloudfront/>

高速コンテンツ配信ネットワーク (CDN) サービスです。キャッシュサーバーみたいなものですね。世界中にエッジサーバーがあるので、世界中どこからでも高速にアクセスできます。また、一定時間データをキャッシュしてくれるので、オリジンサーバへ負担を軽減させることができます。大規模なサービスではDDoS攻撃対策にも利用されているらしいです。また、サーバ証明書を設定することもできます。今回はこちらの目的で利用しました。

### ACM（AWS Certificate Manager）

<https://aws.amazon.com/jp/certificate-manager/>

無料でサーバ証明書が取得できるサービスです。

### Route53

AWSが提供する権威DNSサーバです。EC2やS3、CloudFrontと簡単に連携できるようになっています。

### DynamoDB

NoSQLのデータベースです。超早い。

## フロント部分について

前述の通り、フロント部分はVue.jsを使用しました。

### VueCLI

VueCLIを使ってプロジェクトを作成しました。

### Vue Router

Vue Routerを使ってSPAを構築しました。親ルートは以下のような構成になっています

- 「/」
- 「/kougakusai」（一般出展団体投票ページ用親ルート）
- 「/food-gp」（飲食出展団体投票ページ用親ルート）
- 「/policy」（プライバシーポリシー）
- 「/thanks」（投票完了ページ）
- 「/invalidate」（不正投票検知時に表示するページ）

#### /kougakusai

一般出展団体投票ページ用の親ルートです。子ルートは以下のような構成になっています。

- 「/kougakusai」（投票方法選択画面）
- 「/kougakusai/exhibitList」（出展一覧から選ぶ）
- 「/kougakusai/vote-number」（投票番号から選ぶ）
- 「/kougakusai/keyword」（キーワードから選ぶ）

#### /food-gp

飲食出展団体投票ページ用の親ルートです。子ルートは以下のような構成になっています。

- 「/food-gp」（投票方法選択画面）
- 「/food-gp/exhibitList」（出展一覧から選ぶ）
- 「/food-gp/vote-number」（投票番号から選ぶ）
- 「/food-gp/keyword」（キーワードから選ぶ）

基本的に一般出展団体投票ページと同じような構成になっています。投票可能な団体数が違うなど微妙に異なるところはあるものの画面部分はほぼ同じため、それぞれ共通部分は単一ファイルコンポーネント化しています。

#### /policy

プライバシーポリシーのページです。ここは1ページしかないため構成解説は割愛します。

#### /thanks

投票完了後のサンクスページです。一般出展団体の投票完了後と飲食出展団体の投票完了後に表示されます。ここは画面部分も行う処理もまったく同じため、同一ページを使用しています。

#### /invalidate

不正投票を検知した際に表示するページです。router.beforeEachで不正投票検知の処理を行い、そこで引っかかった場合はこのページに飛びます。（不正投票検知についての詳細は[不正投票対策](#不正投票対策)）

### Vuex

Vuexについてはあまり勉強している時間がなかったため、あまり活用することはできませんでした。本来の使い方として正しいかは分かりませんが、いくつかのファイルで共通して使用する処理はmutations、セッション内で保持しておきたい値はstateに登録するという使い方をしました。

#### state

- division
  - 現在見ているページが「/kougausai」以下か「/food-gp」以下かを判別するものです。
  - [/food-gp](#food-gp)でも述べた通り共通処理はコンポーネント化しています。しかし微妙に違う処理を行うところがあるため、コンポーネント側でその処理を分けるために使用しています。
- loading
  - 現在読み込み中かどうかを判別するために使用します。
  - trueならローディング画面を表示、falseなら非表示という使い方をしています。
  - 詳細は[ローディング画面](#ローディング画面)。

#### mutations

- loadingStart
  - stateのloadingをtrueに変更します。
- loadingEnd
  - stateのloadingをfalseに変更します。
- addDivision
  - stateのdivisionを変更します。
- kougakusaiVote
  - 一般出展団体の投票処理を行います。
  - 主に投票先団体をaxiosでLambdaに飛ばす処理を行なっています。
  - 詳細は[一般出展団体](#一般出展団体)。
- foodGpVote
  - 飲食出展団体の投票処理を行います。
  - 主に投票先団体をaxiosでLambdaに飛ばす処理を行なっています。
  - 詳細は[飲食出展団体](#飲食出展団体)。
- voteComplete
  - 投票完了時の処理です。
  - 一般出展団体の投票時のみ使用します。
  - 以下のように、1団体または2団体に投票した時点で投票完了ボタンを押すとこの処理が走ります。
  - 詳細は[投票完了処理](#投票完了処理)

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">投票完了処理 <a href="https://t.co/2CpDUoiIJ6">pic.twitter.com/2CpDUoiIJ6</a></p>&mdash; しんぶんぶん@時間割bot (@shinbunbun_) <a href="https://twitter.com/shinbunbun_/status/1179363775394308096?ref_src=twsrc%5Etfw">October 2, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

- invalidate
  - 不正投票が検知された時に走ります。
  - Cookieに不正投票フラグを登録します。

#### getters

- returnJsonData
  - divisionがkougakusaiの場合は一般出展団体一覧のjsonを、food-gpの場合は飲食出展団体のjsonを返します。
  - 各出展団体のデータは以下のような形のjsonファイルになっています。

<img width="1440" alt="スクリーンショット 2019-10-02 19.50.13.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260292/25f143ef-527f-3994-27da-fe21c0c3349c.png">

### 単一ファイルコンポーネント

いくつかのページで共通する処理は単一ファイルコンポーネントにしました。

- choose-voting-way
  - 投票方法の選択画面です。
  - /kougakusai、/food-gpで使用しているコンポーネントです。
  - [division](#state)（Vuexのstate）を確認して、/kougakusaiの場合と/food-gpの場合の処理を分けています。
- exhibit-list
  - 出展一覧から投票するページです。
  - /kougakusai/exhibitList、/food-gp/exhibitListで使用しています。
  - choose-voting-wayと同じように、[division](#state)（Vuexのstate）を確認して、/kougakusaiの場合と/food-gpの場合の処理を分けています。
- help
  - ヘルプ画面のモーダルウィンドウです。
  - modalウィンドウのコンポーネントをもとに作成しています。
- keyword
  - キーワードから投票するページです。
  - v-modelでinputに入力されたキーワードを取得しています。
- LoadingOverlay
  - ローディング画面です。
  - [猫本の実例集](https://cr-vue.mio3io.com/examples/text-animation.html#%E3%83%87%E3%83%A2)を参考に作成しました。
- modal
  - モーダルウィンドウ用のコンポーネントです。
  - モーダルウィンドウの骨組みやスタイルを定義しています。
- myfooter
  - フッターです。
  - 全ページ共通のため、App.vueで読み込んでいます。
- myheader
  - ヘッダーです。
  - 全ページ共通のため、App.vueで読み込んでいます。
- vote-number
  - 投票番号から投票するページです。
  - /kougakusai/vote-number、/food-gp/vote-numberで使用しています。
  - choose-voting-wayと同じように、[division](#state)（Vuexのstate）を確認して、/kougakusaiの場合と/food-gpの場合の処理を分けています。

## バックエンド部分について

### バックエンド部分の概要

APIGateway、Lambda、DynamoDBを使用しました。
言語はNode.jsで書いています。

### API

以下のように、機能によってAPIエンドポイントを分けました。

- /kougakusai
  - 一般出展団体投票処理用のエンドポイントです。
  - 詳細は[一般出展団体](#一般出展団体)。
- /food-gp
  - 飲食出展団体投票処理用のエンドポイントです。
  - 詳細は[飲食出展団体](#飲食出展団体)。
- /transition
  - フロントエンド側で不正投票の疑いがある挙動を検知した場合又は初回アクセス時に叩かれます。
  - 本当に不正投票なのかの最終チェックを行います。
  - 最初は遷移時に毎回叩こうと思っていたので「/transition」という名前で作成しました。変更するのが面倒くさかったのでそのままの名前にしています。
- /vote-complete
  - 投票完了処理用のエンドポイントです。
  - 詳細は[投票完了処理](#投票完了処理)。

## 画面遷移時の処理

```js
// ルーターナビゲーションの前にフック
router.beforeEach(async (to, from, next) => {

  store.commit('loadingStart');
  console.log('router.js, beforeEach');
  console.log(`path:${to.path}`);

  if (to.path !== '/invalidate') {

    const access = Cookies.get('access');
    const invalidate = Cookies.get('invalidate');
    if (invalidate === 'true') {
      // 不正投票検知ページへ遷移
      console.log('invalidate: true');
      next({
        name: 'invalidate'
      });
    } else {
      //CookieとIPアドレスを使用した不正投票判定処理
      console.log('invalidate: false');
      if (!access) {
        console.log(`accessなし: ${access}`);
        //・
        //・省略
        //・
        } else {
        console.log(`accessあり: ${access}`);
      }
    }
  }
  console.log('せんい');
  next();
});
```

- ローディングアニメーションを開始するために、loading（storeのstate）の値をtrueに変更する
- パスが/invalidateだった場合
  - 不正投票検知のページへ遷移
- そうでない場合
  - CookieとIPアドレスを使用した[不正投票判定処理](#不正投票対策)を行う

## 不正投票対策

Cookie&IPアドレスでチェックしています。まずフロント側でCookieのチェックを行い、そこで不正投票の兆候が見られた場合はバックエンド側でIPアドレスのチェックをするようにしています。
そのほかにも、PC・タブレットからのアクセスをブロックしたり（複数端末での投票防止）、Route53を使って海外からのアクセスをブロッキングしたり（VPNを使用した不正投票対策）するなどの対策を講じました。
ただ、PCのブロックに関してはChromeのデベロッパーツールを使えば回避できてしまうので、何か良い方法はないかなーと考えています。

不正投票が検出された場合は不正投票検知の警告画面を表示します。

以下はCookieとIPアドレスを使った不正投票対策の解説です。

### 不正投票判定処理

#### 不正投票判定処理のフロント側

```js
      console.log('invalidate: false');
      if (!access) {
        console.log(`accessなし: ${access}`);
        // let err;
        // let responseData;
        try {
          await axios.post('api_endpoint', {
            'path': to.path,
            'sessionId': Cookies.get('sessionId')
          }
            /*, {
                        withCredentials: true
                      } */
          ).then((data) => {
            if (data.data.status === 'pathFoully') {
              console.log('pathFoully');
              Cookies.set('invalidate', 'true');
              next({
                path: '/invalidate'
              });
            } else if (data.data.status === 'ipFoully') {
              console.log('ipFoully');
              Cookies.set('invalidate', 'true');
              Cookies.set('sessionId', data.data.sessionId);
              next({
                path: '/invalidate'
              });
            } else if (data.data.status === 'sessionFoully') {
              console.log('sessionFoully');
              Cookies.set('invalidate', 'true');
              Cookies.set('access', 'true');
              next({
                path: '/invalidate'
              });
            } else if (data.data.status === 'success') {
              console.log('success');
              Cookies.set('access', 'true');
              Cookies.set('sessionId', data.data.sessionId);
            }
          });
        } catch (e) {
          alert(`エラーが発生しました。最初からやり直して下さい。app.vue,router.beforeEach:${e}`);
          console.error(e);
          next({
            name: 'choosing_page'
          });
          // err = true;
        }
      } else {
        console.log(`accessあり: ${access}`);
      }
```

- Cookieに、初回アクセス時に登録されるCookieである「access」が保存されているか確認する
- 保存されていた場合
  - 何もせず終了
- 保存されていなかった場合
  - 不正投票判定用のAPI（[バックエンド](#バックエンド)）を叩く
  - 不正投票が検知された場合
    - 検知された不正投票の種類（data.data.status）によってCookieを更新する
    - 全種類共通して、'invalidate'には'true'を登録する（不正投票フラグ）
  - 検知されなかった場合（data.data.statusが'success'だった場合）
    - 初回アクセスなので、Cookieの'access'に'true'、'sessionId'にはAPIからのレスポンスに含まれているセッションIDを登録する

#### 不正投票判定処理のバックエンド側

```js
const transitionFunc = async (event) => {

    //レスポンスのインスタンスを生成
    let response = new Response();
    //ヘッダーからIPアドレスを取得
    const ip = event.requestContext.identity.sourceIp;
    //リクエストボディからセッションIDを取得
    let sessionId = JSON.parse(event.body).sessionId;

    //セッションIDが存在する場合
    if (sessionId) {

        //不正投票の可能性があるため、エラーレスポンスを返す
        response.statusCode = 200;
        response.body = JSON.stringify({
            status: "sessionFoully"
        });
        response.headers = {
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Credentials': true
        };
        return response;
        //セッションIDが存在しない場合
    } else {
        //IPアドレスをキーにDBを検索する
        let param = {
            TableName: 'kougakusai2019',
            IndexName: 'ip-index',
            KeyConditionExpression: "#k = :val",
            ExpressionAttributeValues: {
                ":val": ip
            },
            ExpressionAttributeNames: {
                "#k": "ip"
            }
        };
        let promise = await new Promise((resolve, reject) => {
            dynamoDocument.query(param, (err, data) => {
                if (err) {
                    reject(`$sendToGroup query err : {err}`);
                } else {
                    resolve(data);
                }
            });
        });

        //レコードが存在した場合
        //セッションIDを再生成してDBに再登録後、エラーレスポンスを返す
        if (promise.Items[0]) {
            console.log("ipアルヨ");
            //セッションID生成
            sessionId = uuidv4();
            //DBにIPアドレスとセッションIDを登録
            await sessionIdRegister(sessionId, ip);

            //レスポンスをreturn
            response.statusCode = 200;
            response.body = JSON.stringify({
                status: "ipFoully",
                sessionId: sessionId
            });
            response.headers = {
                'Access-Control-Allow-Origin': '*',
                'Access-Control-Allow-Credentials': true
            };
            return response;

            //レコードが存在しなかった場合
        } else {
            console.log("ipナイヨ");
            //ボディから送信元のパスを取得する
            const path = JSON.parse(event.body).path;
            //パスがルートだった場合
            if (path === '/') {
                //普通に初回アクセスなので、初回アクセス時の処理を行い、レスポンスを返す
                response = await firstAccessFunc(event);
                return response;

                //パスがルートでない場合
                //不正投票の可能性があるため、セッションIDを再登録後エラーレスポンスを返す
            } else {
                sessionId = uuidv4();
                await sessionIdRegister(sessionId, ip);

                response.statusCode = 200;
                response.body = JSON.stringify({
                    status: "pathFoully"
                });
                response.headers = {
                    'Access-Control-Allow-Origin': '*',
                    'Access-Control-Allow-Credentials': true
                };
                return response;
            }
        }
    }
};
```

- sessionIdが存在するか確認する
  - 存在する場合
    - Cookieの一部を不正に削除または偽装している可能性が高いため、不正投票と判断して```status :'sessionFoully'```を返す
  - 存在しない場合
    - IPアドレスがDBに保存されているか確認
      - 保存されている場合
        - 新しいsessionIdを発行してIPアドレスと一緒にDBへ再登録後、```status: 'ipFoully'```を返す
      - 保存されていない場合
        - 閲覧しているページのパスを確認
          - トップページの場合
            - 初回アクセスと判定し、```status: 'success'```を返す
          - トップページでない場合
            - IPアドレスを偽装してアクセスしている可能性が高いため、不正投票と判断して```status: 'pathFoully'```を返す

## 投票処理

### 一般出展団体

#### 一般出展団体投票処理のフロント部分

```js
    async kougakusaiVote(state, payload) {
      state.loading = true;
      const id = payload[0];
      const name = payload[1];
      const clubName = payload[2];
      const router = payload[3];
      const sessionId = Cookies.get('sessionId');
      if (Cookies.get('name')) {
        const votedNames = JSON.parse(Cookies.get('name'));
        let i = 1;
        let flag = false;
        console.log(Object.keys(votedNames).length);
        Object.keys(votedNames).forEach(() => {
          console.log(votedNames[i]);
          if (votedNames[i] === name) {
            flag = true;
          }
          i++;
        });
        if (flag) {
          alert('同じ団体に2回以上投票する事は出来ません。もう一度投票する団体を選択して下さい');
          router.push({
            name: 'kougakusai-root'
          });
          return;
        }
      }
      let err = false;
      let data;
      try {
        data = await axios.post('htttps://XXX/kougakusai', {
          'id': id,
          'sessionId': sessionId
        }
          /*, {
                    withCredentials: true
                  } */
        );
      } catch (e) {
        alert(`エラーが発生しました。お手数ですがお近くの執行部員までお問い合わせください。store,mutations,kougakusaiVote:${e}`);
        console.error(e);
        err = true;
        state.loading = false;
      }
      if (data.data.status === 'idsFoully') {
        Cookies.set('invalidate', 'true');
        Cookies.set('voteSession', '3');
        router.push({
          name: 'invalidate'
        });
      }
      if (err) {
        let voteSession = parseInt(Cookies.get('voteSession'), 10);
        voteSession++;
        Cookies.set('voteSession', voteSession);
        this.$store.commit('addDivision', 'none');
        router.push({
          name: 'choosing_page'
        });
      } else {
        let voteSession = parseInt(Cookies.get('voteSession'), 10);
        voteSession++;
        Cookies.set('voteSession', voteSession);
        if (voteSession === 1) {
          Cookies.set('name', JSON.stringify({
            1: name
          }));
          Cookies.set('clubName', JSON.stringify({
            1: clubName
          }));
        } else {
          let names = JSON.parse(Cookies.get('name'));
          let clubNames = JSON.parse(Cookies.get('clubName'));
          names[voteSession] = name;
          clubNames[voteSession] = clubName;
          Cookies.set('name', JSON.stringify(names));
          Cookies.set('clubName', JSON.stringify(clubNames));
        }
        // this.$store.commit('addDivision', 'none');
        console.log(router);
        if (voteSession === 3) {
          router.push({
            name: 'thanks'
          });
        } else {
          router.push({
            name: 'kougakusai-root'
          });
        }
      }
      state.loading = false;
    }
```

storeのmutationsに登録されており、投票ボタンを押した際に呼び出されます。
最初に、引数から投票する団体の投票番号（id）、出展名、出展団体名を取り出します。
同じ出展団体に複数回投票することは出来ないので、投票済みの投票団体ではないことを確認し、一般出展団体投票用のAPIを叩きます。
API側での処理が正常に完了した場合は、Cookieを更新して次のページへ遷移します。

#### 一般出展団体投票処理のバックエンド部分

```js
const kougakusaiFunc = async(event) => {
    const response = new Response();
    const ip = event.requestContext.sourceIp;
    const sessionId = JSON.parse(event.body).sessionId;
    const id = JSON.parse(event.body).id;
    console.log(sessionId);

    let param = {
        TableName: 'kougakusai2019',
        KeyConditionExpression: "#k = :val",
        ExpressionAttributeValues: { ":val": sessionId },
        ExpressionAttributeNames: { "#k": "sessionId" }
    };
    let promise = await new Promise((resolve, reject) => {
        dynamoDocument.query(param, (err, data) => {
            if (err) {
                reject(`err : ${err}`);
            }
            else {
                resolve(data);
            }
        });
    });
    //console.log(promise.Items[0].ids.values.length);
    console.log(`promise: ${promise.Items[0]}`);
    //console.log(`promise.Items[0].ids: ${promise.Items[0].ids}`);
    if (promise.Items[0].ids) {
        const ids = promise.Items[0].ids.values;
        ids[ids.length++] = id;
        if (ids.length > 3) {
            response.statusCode = 200;
            response.body = JSON.stringify({ status: "idsFoully" });
            response.headers = {
                'Access-Control-Allow-Origin': '*',
                'Access-Control-Allow-Credentials': true
            };
            return response;
        }
        else {
            param = {
                TableName: 'kougakusai2019',
                Key: { //更新したい項目をプライマリキー(及びソートキー)によって１つ指定
                    sessionId: sessionId
                },
                ExpressionAttributeNames: {
                    '#i': 'ids'
                },
                ExpressionAttributeValues: {
                    ':ids': dynamoDocument.createSet(ids)
                },
                UpdateExpression: 'SET #i = :ids'
            };
            await new Promise((resolve, reject) => {
                dynamoDocument.update(param, (err, data) => {
                    if (err) {
                        reject(err);
                    }
                    else {
                        resolve(data);
                    }
                });
            });
        }
    }
    else {
        param = {
            TableName: 'kougakusai2019',
            Key: { //更新したい項目をプライマリキー(及びソートキー)によって１つ指定
                sessionId: sessionId
            },
            ExpressionAttributeNames: {
                '#i': 'ids'
            },
            ExpressionAttributeValues: {
                ':ids': dynamoDocument.createSet([id])
            },
            UpdateExpression: 'SET #i = :ids'
        };
        await new Promise((resolve, reject) => {
            dynamoDocument.update(param, (err, data) => {
                if (err) {
                    reject(err);
                }
                else {
                    resolve(data);
                }
            });
        });
    }

    response.statusCode = 200;
    response.body = JSON.stringify({
        status: "success",
        sessionId: sessionId
    });
    response.headers = {
        'Access-Control-Allow-Origin': '*'
    };
    return response;
};
```

一般出展団体投票用APIでは、フロント側から受け取った投票先団体の投票番号をDBへ登録する処理を行なっています。
まず、リクエストからipアドレス、セッションID、投票先団体の投票番号（id）を取得します。
その後、セッションIDをキーにDBを叩き、そのユーザーが投票済みの団体を取得します。もしすでに3団体投票済みの場合は不正投票の可能性があるので、エラーレスポンスを返します。投票済み団体数が2団体以下の場合は、DBへ投票先団体の投票番号を追加して正常終了のレスポンスを返します。

### 飲食出展団体

#### 飲食出展団体投票処理のフロント部分

```js
    async foodGpVote(state, payload) {
      state.loading = true;
      const id = payload[0];
      const name = payload[1];
      const clubName = payload[2];
      const router = payload[3];
      const sessionId = Cookies.get('sessionId');
      let err = false;
      let data;
      try {
        data = await axios.post('https://XXXXX/food-gp', ({
          // 'division': 'food-gp',
          'id': id,
          'sessionId': sessionId
        }));
      } catch (e) {
        alert(`エラーが発生しました。お手数ですがお近くの執行部員までお問い合わせください。store,mutations,food-gpVote:${e}`);
        console.error(e);
        err = true;
      }
      if (data.data.status === 'idsFoully') {
        Cookies.set('invalidate', 'true');
        Cookies.set('food-gp', 1);
        router.push({
          name: 'invalidate'
        });
      }
      if (err) {
        this.$store.commit('addDivision', 'none');
        router.push({
          name: 'choosing_page'
        });
      } else {
        Cookies.set('name', JSON.stringify({
          1: name
        }));
        Cookies.set('clubName', JSON.stringify({
          1: clubName
        }));
        Cookies.set('food-gp', 1);
        // this.$store.commit('addDivision', 'none');
        console.log(router);
        router.push({
          name: 'thanks'
        });
      }
      state.loading = false;
    },
```

一般出展団体と同様、引数から投票先団体の情報を取得します。飲食出展団体は1団体しか投票できないため投票済み団体の確認をする必要はないので、とくに何もせず飲食出展団体投票用のAPIを叩きます。API側の処理が成功したらサンクスページへ遷移します。

#### 飲食出展団体投票処理のバックエンド部分

```js
//飲食出展団体投票処理用の関数
const foodGpFunc = async (event) => {

    //レスポンスのインスタンスを生成
    const response = new Response();
    //ボディからセッションIDを取得
    const sessionId = JSON.parse(event.body).sessionId;
    //ボディから投票先団体の投票番号を取得
    const id = JSON.parse(event.body).id;
    console.log(sessionId);

    //セッションIDをキーにしてDBを検索する
    let param = {
        TableName: 'kougakusai2019',
        KeyConditionExpression: "#k = :val",
        ExpressionAttributeValues: {
            ":val": sessionId
        },
        ExpressionAttributeNames: {
            "#k": "sessionId"
        }
    };
    let promise = await new Promise((resolve, reject) => {
        dynamoDocument.query(param, (err, data) => {
            if (err) {
                reject(`err : ${err}`);
            } else {
                resolve(data);
            }
        });
    });

    //DBから取得したデータに投票番号が存在する場合（既に投票済みの場合）
    //不正投票の可能性があるので、エラーレスポンスを返す
    if (promise.Items[0].foodId) {
        response.statusCode = 200;
        response.body = JSON.stringify({
            status: "idsFoully"
        });
        response.headers = {
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Credentials': true
        };
        return response;

        //まだ投票していない場合
    } else {

        //DBに投票先団体の投票番号を登録
        param = {
            TableName: 'kougakusai2019',
            Key: {
                sessionId: sessionId
            },
            ExpressionAttributeNames: {
                '#i': 'foodId'
            },
            ExpressionAttributeValues: {
                ':foodId': id
            },
            UpdateExpression: 'SET #i = :foodId'
        };
        await new Promise((resolve, reject) => {
            dynamoDocument.update(param, (err, data) => {
                if (err) {
                    reject(err);
                } else {
                    resolve(data);
                }
            });
        });
    }

    //レスポンスをreturn
    response.statusCode = 200;
    response.body = JSON.stringify({
        status: "success",
        sessionId: sessionId
    });
    response.headers = {
        'Access-Control-Allow-Origin': '*'
    };
    return response;
};
```

大まかな構造は一般出展団体投票用APIと変わりないですが、飲食出展団体は1団体しか投票ができないので、そこの部分だけ少し違う処理になっています。

## 投票完了処理

1団体または2団体まで投票した時点で投票を完了することができる機能です

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">投票完了処理 <a href="https://t.co/2CpDUoiIJ6">pic.twitter.com/2CpDUoiIJ6</a></p>&mdash; しんぶんぶん@時間割bot (@shinbunbun_) <a href="https://twitter.com/shinbunbun_/status/1179363775394308096?ref_src=twsrc%5Etfw">October 2, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

### フロント側

```js
async voteComplete(state, router) {
      state.loading = true;
      console.log(router);
      Cookies.set('voteSession', 3);
      let err = false;
      try {
        await axios.post('https://XXXXX/vote-complete', ({
          'status': 'voteComplete',
          'sessionId': Cookies.get('sessionId')
        }));
      } catch (e) {
        alert(`エラーが発生しました。お手数ですがお近くの執行部員までお問い合わせください。store,mutations,kougakusaiVote:${e}`);
        console.error(e);
        err = true;
      }
      if (err) {
        this.$store.commit('addDivision', 'none');
        router.push({
          name: 'choosing_page'
        });
      } else {
        router.push({
          name: 'thanks'
        });
      }
      state.loading = false;
    }
```

投票完了処理には0.5~1秒ほどかかることがあるので、最初にstate.loadingをtrueにしてローディングアニメーションを開始します。
その後、Cookieを更新してから投票完了処理を行うAPIを叩きます。
処理が正常に完了した場合は、ローディングアニメーションを終了してサンクスページへ遷移します。

### バックエンド

```js
//投票完了処理用の関数
const voteCompletefunc = async (event) => {
    //レスポンスのインスタンスを生成
    const response = new Response();
    //ボディからセッションIDを取得
    const sessionId = JSON.parse(event.body).sessionId;
    console.log(`sessionId: ${sessionId}`);

    //セッションIDをキーにDBを検索
    let param = {
        TableName: 'kougakusai2019',
        KeyConditionExpression: "#k = :val",
        ExpressionAttributeValues: {
            ":val": sessionId
        },
        ExpressionAttributeNames: {
            "#k": "sessionId"
        }
    };
    let promise = await new Promise((resolve, reject) => {
        dynamoDocument.query(param, (err, data) => {
            if (err) {
                reject(`err : ${err}`);
            } else {
                resolve(data);
            }
        });
    });

    //DBより取得したデータから投票済み団体の投票番号を取得
    const ids = promise.Items[0].ids.values;
    console.log(`ids: ${ids}`);
    //idsの長さ（投票済み団体の数）を取得
    const idsLength = ids.length;
    console.log(`ids: ${ids.length}`);

    //1団体に投票済みの場合
    if (idsLength === 1) {
        //ダミーデータを追加
        ids[2] = 200;
        ids[3] = 0;

        //2団体に投票済みの場合
    } else {
        //ダミーデータを追加
        ids[3] = 0;
    }
    console.log(`ids2: ${ids}`);

    //ダミーデータを追加した投票番号一覧をDBへ再登録
    param = {
        TableName: 'kougakusai2019',
        Key: {
            sessionId: sessionId
        },
        ExpressionAttributeNames: {
            '#i': 'ids'
        },
        ExpressionAttributeValues: {
            ':ids': dynamoDocument.createSet(ids)
        },
        UpdateExpression: 'SET #i = :ids'
    };
    await new Promise((resolve, reject) => {
        dynamoDocument.update(param, (err, data) => {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        });
    });

    //レスポンスをreturn
    response.statusCode = 200;
    response.body = JSON.stringify({
        status: "success",
        sessionId: sessionId
    });
    response.headers = {
        'Access-Control-Allow-Origin': '*'
    };
    return response;
};
```

ボディからセッションIDを取得してそれをキーにDBを検索し、DBより取得したデータから投票番号を取り出して投票済み団体数をチェックします。投票済み団体数に応じて投票番号の配列にダミーデータを追加して、投票番号の配列をDBに再登録しています。

## PC用投票ページ

スマホを持っていない人向けで校門前の受付に投票用PCを設置しました。インターネットに接続することができない環境だったので、投票データは配列としてlocalStorageに保存しておき、後から読み出して集計を行いました。

## つまずいた点など

### 大量のバグ

大量のバグが発生しました。思い出したくない。

### ドメイン利用制限事件

文化祭本番前日である2019年9月27日23時04分。最終チェックをするために投票ページへアクセスしようとした僕は、突如投票ページにアクセス出来なくなっていることに気がついたのだった...。

ということで、本番前日の夜に突如として投票ページにアクセス出来なくなるという、冷や汗じゃすまないレベルの事件が起きました。

結果としては、ドメインのメール認証をし忘れていたせいで利用制限がかかってしまったというインシデントでした。夜のうちに原因解明と利用制限解除ができたのでことなきを得ましたが、もしこれが本番真っ只中で起こっていたらと思うと冷や汗がでます。僕の当時の焦りを少しでも伝えられればと思い、当時の状況を時系列順に並べてみました。終始半泣きです。

| 時間            | 出来事                                                                |
| --------------- | --------------------------------------------------------------------- |
| 2019/9/27 21:17 | ドメイン利用制限がかかる                                              |
| 21:22           | 【重要】[お名前.com] ドメイン利用制限設定　完了通知というメールが届く |
| 23:04           | 投票ページにアクセス出来なくなったことが判明                          |
| 23:16           | DNS系のエラーであることが判明                                         |
| 23:30           | whoisなどを調べた結果、ドメインに利用制限がかかっていることに気付く   |
| 23:35           | メールが届いていたことに気付く                                        |
| 23:39           | 認証を通す                                                            |
| 23:51           | 利用制限解除を確認                                                    |
| 23:53           | 投票ページにアクセスできるようになっていることを確認                  |

悲痛の叫び

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">やばいやばいやばい<br>運用前日なのにDNSでエラー吐いた</p>&mdash; しんぶんぶん (@shinbunbun_) <a href="https://twitter.com/shinbunbun_/status/1177591772920176640?ref_src=twsrc%5Etfw">September 27, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

マジで焦った

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">原因はドメインのメール認証をし忘れていてclientHoldがかかっていたからでした。<br>認証期日が今日の21:30とかだったから、それを過ぎた瞬間、急にアクセスできなくなったらしいです。<br>ちなみに解除申請出したら15分くらいで通って、今は復旧しました。マジで焦った。 <a href="https://t.co/bCAA0DwSSI">https://t.co/bCAA0DwSSI</a></p>&mdash; しんぶんぶん (@shinbunbun_) <a href="https://twitter.com/shinbunbun_/status/1177599546349588480?ref_src=twsrc%5Etfw">September 27, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 小ネタ

### ローディング画面

こんなやつです。

![ダウンロード (2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260292/9e56910e-650e-7a59-2c1e-0dd6e5fdb086.gif)

文字の部分は[猫本のサポートページ](https://cr-vue.mio3io.com/examples/text-animation.html#%E3%83%87%E3%83%A2)に載っていたものを使わせて頂きました。

クルクル回るやつもどこかの記事から拾ってきたものなのですが、どの記事だったかは忘れました...。

router.beforeEachが実行されるとすぐにstore.commit('loadingStart')でローディング状態に入ります。フロント側での不正投票検出処理などが完了し、router.afterEachに入るとstore.commit('loadingEnd')でローディング状態を終了します。投票時にも同じローディングアニメーションを使用しています。

### モーダルウィンドウ

出展団体を表示する際にモーダルウィンドウを使用しました。

こちらも[猫本のサポートページ](https://cr-vue.mio3io.com/examples/modal.html#%E3%83%87%E3%83%A2)に載っていたものを参考にしました。

表示するhtmlは[こちらのサイト](https://webnetamemo.com/coding/bootstrap4/201711116162)などを参考にしながらBootstrapのカードを利用して作成しました。

### 集計

[DynamoDBtoCSV](https://github.com/edasque/DynamoDBtoCSV)を利用してdynamoDBのデータをすべてcsvで取得し、vscodeで正規表現を使った置換で余計なものを消してからExcelのピボットテーブルを使って集計を行いました。来年は集計用のLambda関数を作成して一発で集計ができるようにしたい所存。

### ボタン等の画像

イラレで作成し、svgで書き出して埋め込みました。

<img width="1440" alt="スクリーンショット 2019-12-23 17.57.49.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260292/40fefd94-44a8-6c8b-70c5-32517584d881.png">

## 反省点

### 不正投票を厳格化しすぎた

Cookieの改竄やIPアドレスの改竄が検出された場合は一発で投票をブロックする仕様になっていたのですが、それでは初回アクセス時と違うブラウザを使用した時点でブロックされてしまいます。また、スマホの場合は基地局が変わったり電源をオンオフしたりWi-Fiに接続したりするだけでIPアドレスが変わってしまうのですが、それも不正投票扱いとなってしまいます。

アクセス解析をしたところ、不正投票ページへのアクセスが一定数あったので、意図せず不正投票扱いになってしまった人が結構な量いたのではないかと思います。

これでは流石に利便性が最悪なので、同一IPでUAが違う場合は不正投票扱いにせずCookieを再登録することで対応する、Cookieは登録されているが前回アクセス時とIPが違う場合も不正投票扱いにせずDBのIPアドレスを更新するなどしたいなと思っています。

### コード汚い&変数名が適当すぎる

[制作スケジュール](#制作スケジュール)でも書いた通りめちゃめちゃ急いで作ったので、コードが汚い上に変数名が目を覆いたくなるレベルの適当さです。リーダブルコード読んで出直してきます。幸い来年まではまだ1年弱あるのでゆっくり直していければと思っています。ちなみに宿題は3日前くらいからまとめてやるタイプです。

### 宣伝不足

これは本番当日の話ですが、1日目に投票の宣伝をすっかり忘れていたせいで投票数が3桁しかないというとんでもないことが起ってしましました。2日目はラミネート加工したQRコードを中学生に持たせて宣伝しながら校内を回ってもらったところ、1日目の3倍に増えました。そもそも投票の存在を知らないお客さんが多かったので、宣伝をきちんとして周知させていくこともかなり大事だなと思いました。

## ソースコード（GitHub）

<https://github.com/shinbunbun/vote-system-2019>

GitHubにソースコードをあげました。人様に見せられるコードではないので公開しようか迷ったのですが、マサカリを投げて欲しいので公開します。

今回発見できた不具合（改善点）は[反省点](#反省点)で書きましたが、それ以外にも大量のバグがあると思います。また、Vue.jsを使ったのがはじめてだったのできちんとした使い方ができていないと思います。（とくにVuex）どんなに些細なことでも良いので、「ここはこうした方が良いよー」とか「ここ間違ってんじゃオラ」みたいなものがあれば、コメント欄、TwitterのDM、GitHubのPRやissueなどでガンガン教えて頂けますと助かります。

## 参考記事（覚えている範囲で）

以下の記事を参考させて頂きました。ありがとうございます。

- [Vue.js いろいろ便利な裏技集（随時更新していくよ）](https://qiita.com/gurigurico/items/9c10d21461fff497d31d)
- [AWS CloudFrontで極力キャッシュさせたくない時の話](https://qiita.com/shogomuranushi/items/a2367350ea54a8f41257)
- [CloudFrontでS3のウェブサイトをSSL化する](https://qiita.com/jasbulilit/items/73d70a01a5d3b520450f)
- [お名前.comとAWS Route53を使ってS3で静的なページを公開する方法](https://qiita.com/HiroshiUyama/items/4eef62b4ef2d13f9234d)
- [vuexで登場する分割代入の説明](https://qiita.com/mogya/items/7b4f9a2f3fa08909a135)
- [Vue.js + Bootstrap4でポートフォリオサイトの雛形を作ろう！](https://qiita.com/masa08/items/3474d97a1283dfc275c9)
- [vue.jsのaxios内でthisを使う](https://qiita.com/akamt/items/a970179edbb6fa7f3504)

## 終わりに

長いですねこれ。1000行を超えるQiita書いたのはじめてです。ここまで読んでいる人はいないんじゃないかなーと思いながらこの文章を書いています。あと、思いつきで書き進めたので多分文章めちゃくちゃです。ごめんなさい。長すぎて誰も読まない気はしましたが、後輩のために頑張って書きました。僕が卒業しても引き継いでね。