見出し画像

超格安で独自ASP(アフィリエイトサービス管理システム)を自作&運用できるコードファイル

これまで、独自のアフィリエイトサービスプロバイダ( ≒ 友達紹介URL発行管理システム)を構築するためには、専門知識や高額な開発費用が必要でした。
そのため、零細企業にとってはハードルが高かったのが実情です。

しかし、今は生成AIのおかげもあって、ノートパソコンさえあれば誰でもそういった管理システムを簡単に作成できるようになりました。

この記事では、弊社が実際に運用しているアフィリエイト広告配信&管理システムのコードファイル(激安買い切り版)を案内しています。

これはどなたでも気軽に利用でき、売り上げアップを求める零細企業だけでなく、フリーランスでWEBサービスを制作されている方にもメリットのある話となっています。どうぞご期待ください。

アフィリエイター専用の管理画面(サンプルサイトは上記画像をクリック)

はじめに


零細企業や個人商店が、もっと売り上げを増やすにはどうすれば良いか?

その答えは

「営業マンをたくさん雇う」?

いやいや、売れる営業マンなら良いですけど、売れない営業マンを雇ってしまった日には大変です。。

今は、やはりこれでしょう

「インフルエンサーに紹介してもらう」

成功報酬型のアフィリエイトなら、販促コストを実際に売れた分のみに抑えることができるため、経費削減にもつながります。

アフィリエイトというとネガティブなイメージを抱く方も居られるかもしれませんが、近年はSNS上で「お友達紹介コード」といったカタチで広めている企業も増えています。

【事例①】Bizlink 友だち紹介キャンペーン

これは「お友達紹介コード」を通じて商品が購入されたときに、紹介した側とされた側の両方に、キャッシュバックなどを付与する仕組みとなっています。
インフルエンサーに積極的に自社商品を紹介してもらうためには、やはりそういった仕組みを取り入れることが重要です。

なお、大手のアフィリエイトサービスには、A8ネットやアクセストレード、リンクシェア、バリューコマースなどの事業者があります。
それらを利用すれば、誰でもアフィリエイト広告を運用できますが、それなりにコストはかかりますし、なにかと制限もあります(下記参照)。

・初期費用 0円~50,000円前後
・月額費用 0円~40,000円前後
・手数料 アフィリエイターへ支払う成果報酬額の30%前後
・各社それぞれにシステム上の制限や、独自ルールの設定があります

「A8.netアフィリエイト料金体系」より引用

大企業なら広告やシステム運用に大きな予算を付けることができても、零細企業はなかなかそうもいきません。

しかし、冒頭でも述べたように今は生成AIのおかげで、誰でも独自のアフィリエイトサービス専用システムを簡単に導入できるようになっています。

超格安システムファイル!

実際の話、ここでダウンロードできるシステムファイルを使えば、なんと月額わずか1,000円程度で「独自のお友達紹介システム」を運用できてしまいます!

【事例②】弊社のアフィリエイトサービス

独自のお友達紹介システムを作ろう

自社専用のシステムを運用するというと、難しそうとか、初期費用が高くつくと思われるかもしれません。

安心してください、大丈夫です。
ウェブページのデザイン(見た目)や細かな機能にこだわらなければ、自前のシステムを超ローコストで運用できます。

ちなみに、アフィリエイトの独自システムを販売している会社さんのサービス料金をリサーチしてみたところ、月額で3.3万~20万円となっていました(さらに初期費用もかかるようです)。

デジマ部「おすすめのアフィリエイトシステム」より

優秀な営業マンを一人を雇用したと考えれば、それも悪くはないとはいえ、けっして安い投資ではありませんよね。

ところが、ここで案内するシステムなら、なんと月額1,000円未満で同様の機能を利用できます。冗談ではなく本当の話です。

すでに大手のアフィリエイトサービスプロバイダ(以降は’ASP’に略)を利用されている方も、独自のお友達紹介システムをあわせて自社運用することで相乗効果が見込めます。

これまで通り大手ASPを利用しつつ、そこで購入に至ったお客様には、自社で用意したお友達紹介コードを使ってSNSなどで広告してもらうことで、売り上げアップの可能性がグッと広がるわけです。


弊社でも自社と他社、両方のアフィリエイト広告配信システムを運用していますが、大事なのはサービスの自由度を高めることですね。

自社で運営するASPに複雑な機能はあまり必要なく、それよりも自由にカスタマイズできることが重要です。
つまり、運用ルールは他社ではなく自社で決められるほうが良い、ということです。

そこに独自システムを運用する大きな意味があります。

システムの構築や運用に、とくに難しいことはありません。
具体的な手順を説明すると、次のようになります。


自社専用システムの構築方法

必要なコードファイルと、データベースファイルをここでダウンロードします。

 任意のレンタルサーバー会社で、お好きなドメインとVPS(=専用仮想サーバー)を契約します(あわせて月額1,000円前後)。

コードファイルとデータベースファイルをVPSにアップロードして、必要な設定を行います(1~2時間で完了)。

コードとデータベースを、自社サービスにあわせて自由に調整します(最低限の調整なら1~2時間で完了)。


たったこれだけで「自社専用のお友達紹介コード発行&管理システム」を運用できるようになります。
シンプルなコードで、カスタマイズの自由度になんら制限はありません。

設定方法につきましては、どなたにも分かりやすい説明書(Windows版)を後ほど案内いたします。

サンプルサイトのご案内


まずは、こちらのサンプルサイトをお試しください。
https://sfp-sample3.net/

https://sfp-sample3.net/

ご自身のメールアドレスで新規登録すると、ダッシュボードに表示されるサンプル用アフィリエイトリンクを色々とお試しいただけますので、具体的な使用感もつかめると思います。

・サンプル用アフィリエイトリンク①
2種類の購入ボタンを設置したサンプルページに遷移します

・新規登録用アフィリエイトリンク
新規登録ページに遷移します

・サンプル用アフィリエイトリンク②
2種類の購入ボタンを設置したサンプルページに遷移します

・サンプル用アフィリエイトリンク③
外部ドメインのサンプル購入ページに遷移します

https://sfp-sample3.net/sales
メールフォームタイプのサンプルページです(商品ID 111に対応)

なお、上記いずれも管理者宛てに、成果発生報告メールなどが届くようになっていますが、

管理者として使用するためには、いくつかの認証や登録が必要になるため、サンプルサイトでは残念ながらお試しいただけません。

その代わり、下記URLにて管理者専用ページ(/admin_rewards)を含めた、各ページの簡単な説明を行っています。
https://corp.sfplan.jp/asp-files-explanation/


このASP管理システムのファイルコードを入手すると、サンプルサイトとまったく同じシステムを、誰でもすぐに(=当日中に)立ち上げることができます。

デザイン(見た目)に関しては、まったく手をかけていませんが、だからこそ自由なカスタマイズも可能となっています。

ちなみにデザイン面の改変だけなら、ココナラやクラウドワークスなどで5,000~1万円程度で請け負ってくれるフリーランスがいくらでも見つかります。

ココナラ ~HTML・CSSコーディング~より引用

プログラミングの心得のある人ならご承知の通り、デザインのカスタマイズはHTMLとCSSだけで自由自在です。

また、プログラミングが得意な人は、ここでダウンロードしたファイルを叩き台にして、さらに優れたシステムに改良してみるのも良いでしょう。

たとえばユーザビリティ面などを手直しして、オンラインやオフラインで30万~50万円以上で販売してみてはいかがでしょうか。

コードファイルそのものの転売は著作権法上、許可していませんが、コードの改良によって新たなシステムに変わっていれば、そのファイルの販売はもちろん自由です。

プログラミングは分からないという人でも生成AIを活用すれば、改良はそう難しくはありません。
そもそも別に改良しなくても、このままでもセキュリティ対策も含め、必要な機能をすべて備えています(あまり手をかけていないのはデザイン面だけです)。

それが5万円で手に入るという話は、どこを探してもないはずです。

独自ASPシステムのコードファイル

独自ASP(お友達紹介システム)を構築するために、必要なフォルダとファイルの一覧、全体像は次の通りです。

5つのフォルダと13個のファイル、そしてデータベースをここでダウンロードして、後ほど案内する手順に沿って設定するだけで、他社が月額料金8万円~16万円以上で販売しているようなASPシステムを、ひと月わずか1,000円程度で運用できるようになります。

もちろん、登録ユーザー数がそれなりに増えてくれば(1,000名超など)、利用サーバーの見直しやシステム改良の余地も出てくるかもしれません。
そのときはあらためて弊社にご相談いただくか、他社サービスへの移行を検討すれば良いでしょう。
データさえあれば、他社サービスへの移行に支障はありません。

計18個のフォルダとファイルの解説


今から案内する計18個のフォルダとファイルは、後ほど一括でダウンロードできます。

■ emailsフォルダ

アフィリエイターから確定報酬の請求メールが送信されたときに、そのメール内容がテキスト形式でこのフォルダ内に保存されます。
デフォルトでは’空’のフォルダです。

■ backupsフォルダ

データベースファイルを毎日0時と12時に自動保存し、このbackupsフォルダに格納します。常に直近3日分のデータが保存され、古いデータは自動的に削除されます。これもデフォルトでは空のフォルダとなっています。

■ privateフォルダ

アフィリエイター専用のマイページを構成するHTMLコードと、CSSファイルおよびJavascriptファイルが格納されています。
このフォルダ内の各コードの説明は、説明の順番上、後ほど行います。

■ publicフォルダ

インターネット上に公開される、各ページの表示に必要なHTMLコードと、CSSファイルおよびJavascriptファイル、そして画像情報(imgフォルダ)が格納されています。各コードの説明は後ほど行います。

■ auth.jsファイル

auth.jsファイルには、データベースでのデータ取得が失敗した場合に最大5回まで再トライする処理や、各種ユーザーの認証チェックを行うためのコードを記述しています。

const pool = require('./db'); // データベース接続プールをインポート
const bcrypt = require('bcryptjs'); // パスワードのハッシュ化ライブラリをインポート
const retry = require('async-retry'); // リトライ機能パッケージをインポート
const nodemailer = require('nodemailer'); // メール送信ライブラリをインポート
const { sendErrorNotification } = require('./errorNotifier'); // エラーメール送信関数をインポート

// メールトランスポータの設定
const transporter = nodemailer.createTransport({
  host: process.env.EMAIL_HOST,
  port: 465,
  secure: true,
  auth: {
    user: process.env.EMAIL_USER,
    pass: process.env.EMAIL_PASS
  }
});

// データベースクエリをリトライする関数
async function poolQueryWithRetry(query, values) {
  return retry(async bail => {
    try {
      // クエリを実行し、結果を取得
      const [results] = await pool.query(query, values);
      return results;
    } catch (error) {
      // データベースが見つからない場合のエラーハンドリング
      if (error.code === 'ER_BAD_DB_ERROR') {
        bail(new Error('Database not found')); // リトライせずにエラーをスロー
      } else {
        throw error; // その他のエラーは再度スローしてリトライを継続
      }
    }
  }, {
    retries: 5, // 最大5回リトライ
    minTimeout: 1000, // リトライ間隔の最小値(ミリ秒)
    maxTimeout: 3000, // リトライ間隔の最大値(ミリ秒)
  });
}

// ユーザー認証チェックのミドルウェア
async function checkAuthentication(req, res, next) {
  // ユーザーがログインしているか確認
  if (req.session.user && req.session.loggedin) {
    try {
      // ユーザーIDでデータベースをクエリ
      const userResults = await poolQueryWithRetry('SELECT * FROM users WHERE id = ?', [req.session.user.id]);

      if (userResults.length === 0) {
        // ユーザーが見つからない場合はセッションを破棄
        req.session.destroy(() => {
          handleSessionExpiration(req, res); // セッション切れ時の処理を共通化
        });
      } else {
        // 二段階認証が完了しているか確認
        if (req.session.user.twoFactorAuthenticated || req.session.user.isTwoFactorSkipped) {
          next(); // 認証OKなら次のミドルウェアへ
        } else {
          res.redirect('/login'); // 二段階認証が未完了の場合、ログインページにリダイレクト
        }
      }
    } catch (error) {
      console.error('checkAuthentication Error:', error);

      // エラーメール送信
      try {
        await sendErrorNotification(transporter, 'checkAuthentication Error', `エラーが発生しました: ${error.message}\nスタックトレース:\n${error.stack}`);
      } catch (emailError) {
        console.error('エラーメールの送信に失敗しました:', emailError);
      }

      res.status(500).send('サーバー内部エラー');
    }
  } else {
    handleSessionExpiration(req, res); // セッション切れ時の処理を共通化
  }
}

// セッション切れ時のリダイレクト処理を共通化
function handleSessionExpiration(req, res) {
  const currentPath = req.path;

  if (req.xhr || req.headers.accept.indexOf('json') > -1) {
    // AJAXリクエストの場合はJSONとステータスコードを返す
    res.status(401).json({ message: 'セッションが切れています。' });
  } else {
    // 通常のリクエストの場合、リクエストされたパスに応じてリダイレクト先を変更
    if (currentPath.startsWith('/admaster') || currentPath.startsWith('/admin_rewards')) {
      res.redirect('/adlogin');
    } else {
      res.redirect('/login');
    }
  }
}

// 管理者認証チェック用ミドルウェア
async function checkAdminAuthentication(req, res, next) {
  // ユーザーがログインしているかを確認
  if (req.session.user && req.session.loggedin) {
    try {
      // データベースから現在のユーザー情報を取得

(重要箇所のため省略)
            `);
          }
        } else {
          // 管理者権限がない場合、403エラーを返す
          res.status(403).send('<script>alert("管理者権限がありません"); window.location.href = "/login";</script>');
        }
      }
    } catch (error) {
      console.error('checkAdminAuthentication Error:', error);

      // エラーメール送信
      try {
        await sendErrorNotification(transporter, 'checkAdminAuthentication Error', `エラーが発生しました: ${error.message}\nスタックトレース:\n${error.stack}`);
      } catch (emailError) {
        console.error('エラーメールの送信に失敗しました:', emailError);
      }

      // サーバーエラーが発生した場合、500エラーを返す
      res.status(500).json({ success: false, message: 'サーバー内部エラー' });
    }
  } else {
    // セッションがない場合、403エラーを返す
    res.status(403).send('<script>alert("セッションが切れています。ログインページに移動します。"); window.location.href = "/login";</script>');
  }
}

// 管理者パスワードを検証する関数
async function verifyAdminPassword(req, res, next) {
  const { password } = req.body;

  if (!req.session.user) {
(重要箇所のため省略)
  } catch (error) {
    console.error('verifyAdminPassword Error:', error);

    // エラーメール送信
    try {
      await sendErrorNotification(transporter, 'verifyAdminPassword Error', `エラーが発生しました: ${error.message}\nスタックトレース:\n${error.stack}`);
    } catch (emailError) {
      console.error('エラーメールの送信に失敗しました:', emailError);
    }

    res.status(500).json({ success: false, message: 'サーバー内部エラー' });
  }
}

// 準管理者および管理者セッションチェックのミドルウェア
async function checkAdminOrSubAdminSession(req, res, next) {
  if (req.session.user && req.session.loggedin) {
    try {
      const userResults = await poolQueryWithRetry('SELECT * FROM users WHERE id = ?', [req.session.user.id]);

      if (userResults.length === 0) {
(重要箇所のため省略)
      // エラーメール送信
      try {
        await sendErrorNotification(transporter, 'checkAdminOrSubAdminSession Error', `エラーが発生しました: ${error.message}\nスタックトレース:\n${error.stack}`);
      } catch (emailError) {
        console.error('エラーメールの送信に失敗しました:', emailError);
      }

      res.status(500).json({ success: false, message: 'サーバー内部エラー' });
    }
  } else {
    next(); // セッションがない場合、次に進む(リダイレクトなし)
  }
}

// 各ミドルウェア関数モジュールをエクスポート
module.exports = { checkAuthentication, checkAdminAuthentication, verifyAdminPassword, checkAdminOrSubAdminSession, poolQueryWithRetry };


認証チェックでは、ユーザーがログインしているか、二段階認証が完了しているか、管理者権限を持つユーザーであるか、といった確認を行います。
このファイルはカスタマイズの必要はなく、VPSにアップロードするだけです。

■ affiliates.jsファイル

affiliates.jsファイルは、アフィリエイトサービスにおける主要なデータ管理機能を備えています。

const express = require('express'); // Expressフレームワークをインポート
const router = express.Router(); // ルーターオブジェクトを作成
const pool = require('./db'); // データベース接続プールをインポート
const csrf = require('csrf'); // CSRFトークン生成と検証のためのライブラリをインポート
const tokens = new csrf(); // CSRFトークンのインスタンスを作成
const nodemailer = require('nodemailer'); // メール送信モジュールをインポート
const fs = require('fs'); // ファイルシステム操作モジュールをインポート
const path = require('path'); // ファイルパス操作モジュールをインポート
const { checkAuthentication } = require('./auth'); // 認証ミドルウェアをインポート
const { sendMailAsyncWithRetry, sendErrorNotification } = require('./errorNotifier'); // メール送信関数をインポート

// CSRFトークン検証ミドルウェア
const csrfProtection = (req, res, next) => {
  const token = req.headers['CSRF-Token'] || req.cookies['XSRF-TOKEN']; // ヘッダーまたはクッキーからCSRFトークンを取得
  const secret = req.session ? req.session.csrfSecret : null; // セッションからCSRFシークレットを取得

  if (secret && tokens.verify(secret, token)) {
    next(); // トークンが有効な場合、次のミドルウェアへ進む
  } else {
    res.status(403).send('CSRFトークンが無効です'); // トークンが無効な場合、403エラーを返す
  }
};

// メールトランスポータ設定
const transporter = nodemailer.createTransport({
  host: process.env.EMAIL_HOST, // 環境変数からメールホストを取得
  port: 465, // ポート番号(セキュア接続)
  secure: true, // SSL/TLSを使用するかどうか
  auth: {
    user: process.env.EMAIL_USER, // 環境変数からメールユーザーを取得
    pass: process.env.EMAIL_PASS // 環境変数からメールパスワードを取得
  }
});

// アフィリエイトリンクと短縮URLを取得するエンドポイント
router.get('/affiliates', checkAuthentication, async (req, res) => {
  try {
    const userId = req.session.user.id; // セッションからユーザーIDを取得

    // データベースからアフィリエイトリンク、短縮URL、そしてcustom_priceを取得
(重要箇所のため省略)
    // エラーメール送信
    try {
      await sendErrorNotification(transporter, 'Affiliate Links Fetch Error', `エラーが発生しました: ${error.message}\nスタックトレース:\n${error.stack}`);
    } catch (emailError) {
      console.error('エラーメールの送信に失敗しました:', emailError);
    }

    res.status(500).json({ error: 'アフィリエイトリンク取得エラー' }); // 500エラーとしてレスポンスを返す
  }
});

// 成果発生通知の切替用エンドポイント
router.post('/toggle-notification', checkAuthentication, async (req, res) => {
  const userId = req.body.userId; // リクエストボディからユーザーIDを取得
  const newNotificationStatus = req.body.notification; // リクエストボディから新しい通知ステータスを取得

  try {
    // ユーザーの通知設定をデータベースで更新
    await pool.query('UPDATE users SET notification = ? WHERE id = ?', [newNotificationStatus, userId]);
    res.json({ success: true }); // 成功レスポンスを返す
  } catch (err) {
    console.error('通知設定の切替中にエラーが発生しました:', err); // エラーメッセージをコンソールに出力

    // エラーメール送信
    try {
      await sendErrorNotification(transporter, '通知設定切替エラー', `エラーが発生しました: ${err.message}\nスタックトレース:\n${err.stack}`);
    } catch (emailError) {
      console.error('エラーメールの送信に失敗しました:', emailError);
    }

    res.status(500).json({ error: '通知設定の切替に失敗しました', details: err.message }); // エラー情報を含むレスポンスを返す
  }
});

// 銀行口座情報の登録用エンドポイント
router.post('/register-bank-info', checkAuthentication, csrfProtection, async (req, res) => {
  // リクエストボディから銀行口座情報を取得
  const { userId, bankName, branchName, branchCode, accountType, accountNumber, accountHolderName, accountHolderKana } = req.body;

  // 銀行口座情報を挿入または更新するクエリ
  const query = `
(重要箇所のため省略)
    // エラーメール送信
    try {
      await sendErrorNotification(transporter, '銀行口座情報確認エラー', `エラーが発生しました: ${error.message}\nスタックトレース:\n${error.stack}`);
    } catch (emailError) {
      console.error('エラーメールの送信に失敗しました:', emailError);
    }

    res.status(500).json({ error: '銀行口座情報の確認に失敗しました' }); // エラー情報を含むレスポンスを返す
  }
});

// 銀行口座情報の取得エンドポイント
router.get('/get-bank-info', checkAuthentication, async (req, res) => {
  const userId = req.query.userId; // クエリパラメータからユーザーIDを取得

  // 銀行口座情報を取得するクエリ
  const query = `
(重要箇所のため省略)
    // エラーメール送信
    try {
      await sendErrorNotification(transporter, '支払い可能額取得エラー', `エラーが発生しました: ${error.message}\nスタックトレース:\n${error.stack}`);
    } catch (emailError) {
      console.error('エラーメールの送信に失敗しました:', emailError);
    }

    res.status(500).json({ error: '支払い可能額の取得に失敗しました' }); // エラー情報を含むレスポンスを返す
  }
});

// 確定報酬額を請求するエンドポイント
router.post('/claim-rewards', checkAuthentication, csrfProtection, async (req, res) => {
  const { userId } = req.body; // リクエストボディからユーザーIDを取得
  const claimDate = new Date(); // 現在の日付を取得

  // 日付のフォーマットを修正
  const formattedClaimDate = claimDate.toLocaleString('ja-JP', { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' });

  try {
    // ユーザー情報の取得
    const [userResults] = await pool.query('SELECT username, email FROM users WHERE id = ?', [userId]);
    const username = userResults[0].username; // ユーザー名を取得
    const email = userResults[0].email; // メールアドレスを取得

    // 支払い可能額の計算
    const [payableResults] = await pool.query(`
          SELECT SUM(confirmed_rewards) AS payableAmount
          FROM clicks
          WHERE user_id = ? AND is_claimed = FALSE
      `, [userId]);
    const payableAmount = payableResults[0].payableAmount || 0; // 支払い可能額を取得

    // 支払い可能額が5,000円未満の場合はエラーを返す
    if (payableAmount < 5000) {
      return res.status(400).json({ error: 'お支払いの受付は5,000円以上からになります。' });
    }

    // 銀行口座情報の取得
    const [bankResults] = await pool.query('SELECT * FROM bank_accounts WHERE user_id = ?', [userId]);
    if (bankResults.length === 0) {
(重要箇所のため省略)
    // メール内容をファイルに保存
    const emailContent = `
          ユーザーID: ${userId}
          ユーザー名: ${username}
          請求金額: ${payableAmount} 円
          請求日時: ${formattedClaimDate}
          銀行名: ${bankInfo.bank_name}
          支店名: ${bankInfo.branch_name}
          支店コード: ${bankInfo.branch_code}
          口座種別: ${bankInfo.account_type}
          口座番号: ${bankInfo.account_number}
          口座名義人: ${bankInfo.account_holder_name}
          名義人フリガナ: ${bankInfo.account_holder_kana}
      `;

    // メール内容をファイルに保存
    const filePath = path.join(__dirname, 'emails', `claim_${userId}_${claimDate.getTime()}.txt`);
    fs.writeFileSync(filePath, emailContent, 'utf8');

    // ユーザーへの控えメール
    const userMailOptions = {
      from: `"${process.env.EMAIL_FROM_NAME}" <${process.env.EMAIL_USER}>`,
      to: email,
      subject: `【ご請求の控え】請求を受け付けました`,
      html: `
              <p>${username} 様</p>
              <p>いつもお世話になっております。</p>
              <p>以下の内容で請求を受け付けました。ご確認ください。</p>
              <table>
                  <tr>
                      <th>ユーザー名</th><td>:${username}</td>
                  </tr>
                  <tr>
                      <th>請求金額</th><td>:${payableAmount} 円</td>
                  </tr>
                  <tr>
                      <th>請求日時</th><td>:${formattedClaimDate}</td>
                  </tr>
              </table>
              <p>ご不明点等がございましたら、お気軽にお問い合わせください。</p>
              <p>今後ともよろしくお願い申し上げます。</p>`
    };

    // 管理者とユーザーにメール送信
    await sendMailAsyncWithRetry(transporter, mailOptions); // 管理者にメール送信
    await sendMailAsyncWithRetry(transporter, userMailOptions); // ユーザーに確認メール送信

    res.json({ success: true }); // 成功レスポンスを返す
  } catch (error) {
    console.error('報酬額請求中にエラーが発生しました:', error); // エラーメッセージをコンソールに出力

    // エラーメール送信
    try {
      await sendErrorNotification(transporter, '報酬額請求エラー', `エラーが発生しました: ${error.message}\nスタックトレース:\n${error.stack}`);
    } catch (emailError) {
      console.error('エラーメールの送信に失敗しました:', emailError);
    }

    res.status(500).json({ error: '報酬額請求に失敗しました' }); // エラー情報を含むレスポンスを返す
  }
});

module.exports = router; // ルーターをエクスポート


affiliates.jsの主な機能は次の5つです。

・ログインユーザーのアフィリエイトリンクとその短縮URLを取得する機能
・アフィリエイト成果発生通知の切替機能
・銀行口座情報の登録および確認機能
・アフィリエイターへの報酬支払い可能額の計算機能
・確定報酬額の請求およびメール内容の保存機能

このファイルもVPSにアップロードするだけで問題ありませんが、カスタマイズすべき箇所としては、以下のコード部分があります。

    // 支払い可能額が5,000円未満の場合はエラーを返す
    if (payableAmount < 5000) {
      return res.status(400).json({ error: 'お支払いの受付は5,000円以上からになります。' });
    }

デフォルトでは、アフィリエイターへの最低支払い可能額を「5,000円」からとしています。この金額を例えば「1,000円」としたい場合には、次のように変更してください。

    // 支払い可能額が1,000円未満の場合はエラーを返す
    if (payableAmount < 1000) {
      return res.status(400).json({ error: 'お支払いの受付は1,000円以上からになります。' });
    }

なお、最低支払い額の設定が不要な場合には、次のように変更すると良いでしょう。

    // 支払い可能額が1円未満の場合はエラーを返す
    if (payableAmount < 1) {
      return res.status(400).json({ error: 'お支払いの受付は1円以上からになります。' });
    }


また、ユーザー(アフィリエイター)からの報酬額請求時には、下記のコード部分でメールを自動送信しています。この文章をご自身のビジネスに応じて、のちほど適切な内容に書き換えてください。

    // ユーザーへの控えメール
    const userMailOptions = {
      from: `"${process.env.EMAIL_FROM_NAME}" <${process.env.EMAIL_USER}>`,
      to: email,
      subject: `【ご請求の控え】請求を受け付けました`,
      html: `
              <p>${username} 様</p>
              <p>いつもお世話になっております。</p>
              <p>以下の内容で請求を受け付けました。ご確認ください。</p>
              <table>
                  <tr>
                      <th>ユーザー名</th><td>:${username}</td>
                  </tr>
                  <tr>
                      <th>請求金額</th><td>:${payableAmount} 円</td>
                  </tr>
                  <tr>
                      <th>請求日時</th><td>:${formattedClaimDate}</td>
                  </tr>
              </table>
              <p>ご不明点等がございましたら、お気軽にお問い合わせください。</p>
              <p>今後ともよろしくお願い申し上げます。</p>
          `
    };

タグ(<p></p>など)の使い方がよく分からなくても大丈夫です。
あなたの希望するメール文章と、上記コードをコピー&ペーストして、生成AIにコードの修正を依頼すれば、タグを含めた形で修正済みコードを提示してくれます(生成AIの導入手順や活用方法は、後ほど案内します)。

■ backup.jsファイル

backup.jsファイルには、データベースの自動バックアップを実行するための機能を記述しています。
データベースのバックアップは毎日0時と12時に自動的に行われ、backupsフォルダの中に、常に直近3日分のデータが保管されます。

const { exec } = require('child_process'); // 子プロセスを生成するためのモジュールをインポート
const path = require('path'); // ファイルパス操作用のモジュールをインポート
const fs = require('fs'); // ファイルシステム操作用のモジュールをインポート
const nodemailer = require('nodemailer'); // メール送信ライブラリをインポート
const { sendErrorNotification } = require('./errorNotifier'); // エラーメール送信関数をインポート

// メールトランスポータの設定
const transporter = nodemailer.createTransport({
    host: process.env.EMAIL_HOST, // メールサーバーホストを環境変数から取得
    port: 465, // メールサーバーのポート番号
    secure: true, // SSL/TLSを使用して接続
    auth: {
        user: process.env.EMAIL_USER, // メール送信に使用するユーザー名を環境変数から取得
        pass: process.env.EMAIL_PASS // メール送信に使用するパスワードを環境変数から取得
    }
});

// 必須の環境変数をチェックし、設定されていない場合はプロセスを終了
const requiredEnvVars = ['EMAIL_HOST', 'EMAIL_USER', 'EMAIL_PASS', 'DB_NAME', 'DB_USER', 'DB_PASSWORD'];
requiredEnvVars.forEach(varName => {
    if (!process.env[varName]) {
        console.error(`環境変数 ${varName} が設定されていません`);
        process.exit(1);
    }
});

// グローバルな未捕捉例外と未処理拒否をキャッチするハンドラー
process.on('uncaughtException', async (error) => {
    console.error('未捕捉の例外:', error);
    await sendErrorNotification(transporter, 'Uncaught Exception', `未捕捉の例外が発生しました: ${error.message}\nスタックトレース:\n${error.stack}`);
});

process.on('unhandledRejection', async (reason, promise) => {
    console.error('未処理の拒否:', reason);
    await sendErrorNotification(transporter, 'Unhandled Rejection', `未処理の拒否が発生しました: ${reason}\nスタックトレース:\n${reason.stack}`);
});

const BACKUP_DIR = path.join(__dirname, 'backups'); // バックアップファイルを保存するディレクトリ
const DATABASE_NAME = process.env.DB_NAME; // データベース名を環境変数から取得
const USERNAME = process.env.DB_USER; // データベースのユーザー名を環境変数から取得
const PASSWORD = process.env.DB_PASSWORD; // データベースのパスワードを環境変数から取得

// バックアップを実行する関数
const runBackup = () => {
    const now = new Date();
    const timestamp = now.toISOString().replace(/[:.]/g, '-');  // 現在の日時をタイムスタンプとして取得し、ファイル名に使用できる形式に変換
    const backupFile = path.join(BACKUP_DIR, `${DATABASE_NAME}_backup_${timestamp}.sql`); // バックアップファイルのフルパスを生成

    // mysqldump コマンドを実行して、データベースをエクスポート
    const command = `mysqldump -u${USERNAME} -p${PASSWORD} ${DATABASE_NAME} > ${backupFile}`;

    // 子プロセスとしてコマンドを実行
    exec(command, async (error, stdout, stderr) => {
        if (error) {
            console.error('バックアップエラー:', error); // エラー発生時にコンソールに表示
            await sendErrorNotification(transporter, 'Backup Error', `バックアップ中にエラーが発生しました: ${error.message}\nスタックトレース:\n${error.stack}`); // エラーメール送信
            return; // ここで関数を終了
        }
        console.log('バックアップが正常に作成されました:', backupFile); // バックアップが正常に作成されたことをコンソールに表示

        // 古いバックアップファイルを削除
        cleanupOldBackups();
    });
};

// 古いバックアップファイルを削除する関数
const cleanupOldBackups = () => {
    fs.readdir(BACKUP_DIR, (err, files) => {
        if (err) {
            console.error('バックアップディレクトリの読み込み中にエラーが発生しました:', err);
            sendErrorNotification(transporter, 'Cleanup Error', `バックアップディレクトリの読み込み中にエラーが発生しました: ${err.message}\nスタックトレース:\n${err.stack}`);
            return;
        }

        // ファイルの最終更新日時を取得するために、非同期の fs.stat を使用
        const fileStatPromises = files
            .filter(file => file.endsWith('.sql'))
            .map(file => {
                return new Promise((resolve, reject) => {
                    const filePath = path.join(BACKUP_DIR, file);
                    fs.stat(filePath, (err, stats) => {
                        if (err) {
                            return reject(err);
                        }
                        resolve({ file, time: stats.mtime.getTime() });
                    });
                });
            });

        // すべてのファイルの stat が完了したら処理を続ける
        Promise.all(fileStatPromises)
            .then(fileStats => {
                // 最新のものから順にソート
                fileStats.sort((a, b) => b.time - a.time);

                // 最新の6つ以外のバックアップファイルを削除
                fileStats.slice(6).forEach(fileObj => {
                    const filePath = path.join(BACKUP_DIR, fileObj.file);
                    fs.unlink(filePath, err => {
                        if (err) {
                            console.error(`${filePath} の削除中にエラーが発生しました:`, err);
                            sendErrorNotification(transporter, 'File Deletion Error', `${filePath} の削除中にエラーが発生しました: ${err.message}\nスタックトレース:\n${err.stack}`);
                        } else {
                            console.log(`${filePath} が削除されました`);
                        }
                    });
                });
            })
            .catch(statError => {
                console.error('ファイルステータス取得中にエラーが発生しました:', statError);
                sendErrorNotification(transporter, 'Stat Error', `ファイルステータス取得中にエラーが発生しました: ${statError.message}\nスタックトレース:\n${statError.stack}`);
            });
    });
};

// 定期的にバックアップを実行
const { CronJob } = require('cron'); // cronジョブを実行するためのモジュールをインポート
const dailyBackupJob = new CronJob('0 0,12 * * *', runBackup); // 毎日0時と12時に実行
dailyBackupJob.start(); // ジョブを開始


このファイルもカスタマイズの必要はありません。アップロードするだけでOKです。

■ db.jsファイル

db.jsファイルは、MySQLデータベースとの接続を一元管理するためのコードを記述しています。
mysql2/promiseモジュールにより、非同期関数を使用してデータベース操作を行います。

const mysql = require('mysql2/promise'); // MySQLデータベースとの非同期通信をサポートするmysql2モジュールをインポート

// データベース接続プールを作成
const pool = mysql.createPool({
  connectionLimit: 10, // 接続プール内の最大接続数を設定
  host: process.env.DB_HOST, // データベースサーバーのホスト名を環境変数から取得
  user: process.env.DB_USER, // データベースに接続するユーザー名を環境変数から取得
  password: process.env.DB_PASSWORD, // ユーザーのパスワードを環境変数から取得
  database: process.env.DB_NAME // 使用するデータベース名を環境変数から取得
});

// 他のファイルでこの接続プールを使用できるようにエクスポート
module.exports = pool;

ユーザー数(=アフィリエイター数)がまだ少ないうちは、このファイルをカスタマイズする必要はありません。アップロードするだけでOKです。

■ stats.jsファイル

stats.jsファイルには、当日、当月、日次、月次、年次それぞれのクリック数、コンバージョン数、未確定報酬額、未確定報酬、確定報酬、拒否件数を集計し、定期的なデータクリーニングを自動的に行う機能を記述しています。

const express = require('express'); // Expressモジュールをインポート
const router = express.Router(); // Expressのルーターオブジェクトを作成
const { CronJob } = require('cron'); // cronジョブを実行するためのCronJobクラスをインポート
const pool = require('./db'); // db.jsから接続プールをインポート
const nodemailer = require('nodemailer'); // メール送信機能を提供するライブラリをインポート
const { checkAuthentication, checkAdminAuthentication, poolQueryWithRetry } = require('./auth'); // 認証ミドルウェアと再試行用クエリ関数をインポート
const { sendErrorNotification } = require('./errorNotifier'); // エラーメール送信関数をインポート

// メールトランスポータの設定
const transporter = nodemailer.createTransport({
  host: process.env.EMAIL_HOST,
  port: 465,
  secure: true,
  auth: {
    user: process.env.EMAIL_USER,
    pass: process.env.EMAIL_PASS
  }
});

// ジョブ失敗時のクエリを保存するリスト
let failedQueries = [];

// 当日統計データの取得エンドポイント
router.get('/today', checkAuthentication, async (req, res) => {
  const userId = req.session.user.id; // ユーザーIDをセッションから取得

  // 当日の統計データを取得するクエリ
  const todayQuery = `
(重要部分のため省略)

  } catch (error) {
    console.error('今日の統計データ取得中にエラーが発生しました:', error);
    res.status(500).json({ error: 'サーバーエラーが発生しました' });
  }
});

// 当月統計データの取得エンドポイント
router.get('/month', checkAuthentication, async (req, res) => {
  const userId = req.session.user.id; // ユーザーIDをセッションから取得

  // 当月の統計データを取得するクエリ
  const monthQuery = `
(重要部分のため省略)
  try {
    const results = await poolQueryWithRetry(monthQuery, [userId]); // クエリを再試行機能で実行し、結果を取得
    const data = results.length > 0 ? results[0] : {}; // 結果が空の場合は空のオブジェクトを設定

    res.json({
      totalConversions: data.totalConversions || 0, // コンバージョン数(結果がなければ0)
      totalPendingRewards: data.totalPendingRewards || 0, // 未確定報酬(結果がなければ0)
      totalConfirmedRewards: data.totalConfirmedRewards || 0, // 確定報酬(結果がなければ0)
      totalRejected: data.totalRejected || 0 // 拒否された件数(結果がなければ0)
    });
  } catch (error) {
    console.error('月の統計データ取得中にエラーが発生しました:', error);
    res.status(500).json({ error: 'サーバーエラーが発生しました' });
  }
});

// 日次統計の取得用エンドポイント
router.get('/daily', checkAuthentication, async (req, res) => {
  const userId = req.session.user.id; // ユーザーIDをセッションから取得

  // 日次統計データを取得するクエリ
(重要部分のため省略)

  try {
    const results = await poolQueryWithRetry(query, [userId]); // クエリを再試行機能で実行
    res.json(results); // 結果をJSON形式でクライアントに返す
  } catch (err) {
    console.error('日次統計の取得中にエラーが発生しました:', err); // エラーのログを出力
    res.status(500).json({ error: '日次統計の取得に失敗しました', details: err.message }); // サーバーエラーレスポンスを送信
  }
});

// 月次統計の取得用エンドポイント
router.get('/monthly', checkAuthentication, async (req, res) => {
  const userId = req.session.user.id; // ユーザーIDをセッションから取得

  // 月次統計データを取得するクエリ
  const query = `
(重要部分のため省略)

  try {
    const results = await poolQueryWithRetry(query, [userId]); // クエリを再試行機能で実行
    res.json(results); // 結果をJSON形式でクライアントに返す
  } catch (err) {
    console.error('月次統計の取得中にエラーが発生しました:', err); // エラーのログを出力
    res.status(500).json({ error: '月次統計の取得に失敗しました', details: err.message }); // サーバーエラーレスポンスを送信
  }
});

// 年次統計の取得用エンドポイント
router.get('/yearly', checkAuthentication, async (req, res) => {
  const userId = req.session.user.id; // ユーザーIDをセッションから取得

  // 年次統計データを取得するクエリ
  const query = `
(重要部分のため省略)

  try {
    const results = await poolQueryWithRetry(query, [userId]); // クエリを再試行機能で実行
    res.json(results); // 結果をJSON形式でクライアントに返す
  } catch (err) {
    console.error('年次統計の取得中にエラーが発生しました:', err); // エラーのログを出力
    res.status(500).json({ error: '年次統計の取得に失敗しました', details: err.message }); // サーバーエラーレスポンスを送信
  }
});

// 日次クリーンアップ関数
const runDailyCleanup = async (date = null) => {
  // 日付の設定(指定された場合はその日付、指定がない場合は前日の日付)
  const yesterday = date ? new Date(date) : new Date();
  if (!date) {
    yesterday.setDate(yesterday.getDate()); 
  }
  const formattedYesterday = yesterday.toISOString().split('T')[0]; // 日付をフォーマット

  // 日付をログに出力して確認
  console.log(`クリーンアップで使用される日付: ${formattedYesterday}`);

  // クリックがあったデータをdaily_statsテーブルに挿入または更新
  const query = `
(重要部分のため省略)

  try {
    await poolQueryWithRetry(query, [formattedYesterday, formattedYesterday]);
    console.log(`前日のデータが正常に集計されました: ${formattedYesterday}`);
(重要部分のため省略)

  } catch (err) {
    console.error('日次クリーンアップジョブの実行中にエラーが発生しました:', err);
    await sendErrorNotification('日次クリーンアップジョブエラー', `エラー内容: ${err.message}`);
  }
};

// 月次クリーンアップ関数
const runMonthlyCleanup = async (date = null) => {
  // 日付が指定されている場合、その日付を使用し、指定がない場合は当月初日の日付を使用
  const firstDayOfMonth = date ? new Date(date) : new Date();
  if (!date) firstDayOfMonth.setDate(1); // 当月の初日を取得
  const formattedFirstDayOfMonth = firstDayOfMonth.toISOString().split('T')[0]; // 日付をフォーマット

  // 日付をログに出力して確認
  console.log(`月次クリーンアップで使用される日付: ${formattedFirstDayOfMonth}`);

  // 日次データから月次データを集計してmonthly_statsテーブルに挿入または更新
(重要部分のため省略)

    }
    console.log('月次クリーンアップが正常に完了しました');
  } catch (err) {
    console.error('月次クリーンアップジョブの実行中にエラーが発生しました:', err);
    await sendErrorNotification('月次クリーンアップジョブエラー', `エラー内容: ${err.message}`);
  }
};

// 年次クリーンアップ関数
const runYearlyCleanup = async (date = null) => {
  // 日付が指定されている場合、その日付を使用し、指定がない場合は当年初日の日付を使用
  const firstDayOfYear = date ? new Date(date) : new Date();
  if (!date) firstDayOfYear.setMonth(0, 1); // 当年の初日を取得
  const formattedFirstDayOfYear = firstDayOfYear.toISOString().split('T')[0]; // 日付をフォーマット

  // 日付をログに出力して確認
  console.log(`年次クリーンアップで使用される日付: ${formattedFirstDayOfYear}`);

  // 日次データから年次データを集計してyearly_statsテーブルに挿入または更新
  const query = `
(重要部分のため省略)

    console.log('年次クリーンアップが正常に完了しました');
  } catch (err) {
    console.error('年次クリーンアップジョブの実行中にエラーが発生しました:', err);
    await sendErrorNotification('年次クリーンアップジョブエラー', `エラー内容: ${err.message}`);
  }
};

// 失敗したクエリの再実行関数
const retryFailedQueries = async () => {
  for (let i = 0; i < failedQueries.length; i++) {
    const { query, date } = failedQueries[i];
    try {
      switch (query) {
        case 'daily_cleanup':
          await runDailyCleanup(new Date(date));
          break;
        case 'monthly_cleanup':
          await runMonthlyCleanup(new Date(date));
          break;
        case 'yearly_cleanup':
          await runYearlyCleanup(new Date(date));
          break;
      }
      console.log(`${query} が正常に再実行されました`);
      failedQueries.splice(i, 1);
      i--;
    } catch (err) {
      console.error(`${query} の再実行に失敗しました:`, err);
      await sendErrorNotification(`${query} の再実行に失敗しました`, `エラー内容: ${err.message}`);
    }
  }
};

// 日次クリーンアップジョブ
const dailyCleanupJob = new CronJob('0 0 * * *', async () => {
  await retryFailedQueries();
  await runDailyCleanup();
});
// 毎日0時に実行される日次クリーンアップジョブを設定

// 月次クリーンアップジョブ
const monthlyCleanupJob = new CronJob('0 0 1 * *', async () => {
  await retryFailedQueries();
  await runMonthlyCleanup();
});
// 毎月1日の0時に実行される月次クリーンアップジョブを設定

// 年次クリーンアップジョブ
const yearlyCleanupJob = new CronJob('0 0 1 1 *', async () => {
  await retryFailedQueries();
  await runYearlyCleanup();
});
// 毎年1月1日の0時に実行される年次クリーンアップジョブを設定

// clicksテーブルのクリーンアップジョブ
const cleanupJob = new CronJob('0 0 * * *', async () => {
  try {
    const query = `
      DELETE FROM clicks
      WHERE conversion = 0 AND click_time < NOW() - INTERVAL 60 DAY
    `; // 60日以上前でコンバージョンが0のクリックデータを削除するクエリ

    const results = await poolQueryWithRetry(query); // クエリを再試行機能で実行
    console.log('クリックデータのクリーンアップジョブが正常に実行されました:', results);
  } catch (err) {
    console.error('クリックデータのクリーンアップジョブの実行中にエラーが発生しました:', err);
    failedQueries.push({ query: 'click_cleanup', date: new Date().toISOString().split('T')[0] });
    await sendErrorNotification('クリックデータクリーンアップジョブエラー', `エラー内容: ${err.message}`);
  }
});
// 毎日0時に実行されるクリックデータのクリーンアップジョブを設定

// Registrationsテーブルの古いレコードを削除する関数
const deleteExpiredRegistrations = async () => {
  try {
(重要部分のため省略)

  } catch (err) {
    console.error('期限切れの登録レコードの削除中にエラーが発生しました:', err);
    failedQueries.push({ query: 'registration_cleanup', date: new Date().toISOString().split('T')[0] });
    await sendErrorNotification('登録レコードクリーンアップジョブエラー', `エラー内容: ${err.message}`);
  }
};

// Registrationsテーブルのクリーンアップジョブ
const registrationCleanupJob = new CronJob('0 0 * * *', async () => {
  await retryFailedQueries();
  await deleteExpiredRegistrations();
  console.log('登録レコードのクリーンアップジョブが実行されました');
});
// 毎日0時に実行される登録レコードのクリーンアップジョブを設定

// 定期的に不要なトークンを削除するタスク
const tokenCleanupJob = new CronJob('0 0 * * *', async () => {
  try {
(重要部分のため省略)

});

// 各ジョブを開始
dailyCleanupJob.start();
monthlyCleanupJob.start();
yearlyCleanupJob.start();
cleanupJob.start();
registrationCleanupJob.start();
tokenCleanupJob.start();

// 手動クリーンアップエンドポイント(日次)
router.post('/manual-cleanup', checkAdminAuthentication, async (req, res) => {
  const { date } = req.body; // フロントエンドから特定の日付を取得

  try {
    await runDailyCleanup(date); // 指定された日付でクリーンアップを実行
    res.json({ message: '日次クリーンアップが正常に実行されました' });
  } catch (err) {
    console.error('日次クリーンアップエラー:', err);
    res.status(500).json({ message: '日次クリーンアップに失敗しました' });
  }
});

// 手動クリーンアップエンドポイント(月次)
router.post('/manual-cleanup/monthly', checkAdminAuthentication, async (req, res) => {
  const { date } = req.body; // フロントエンドから特定の日付を取得

  try {
    await runMonthlyCleanup(date); // 指定された日付でクリーンアップを実行
    res.json({ message: '月次クリーンアップが正常に実行されました' });
  } catch (err) {
    console.error('月次クリーンアップエラー:', err);
    res.status(500).json({ message: '月次クリーンアップに失敗しました' });
  }
});

// 手動クリーンアップエンドポイント(年次)
router.post('/manual-cleanup/yearly', checkAdminAuthentication, async (req, res) => {
  const { date } = req.body; // フロントエンドから特定の日付を取得

  try {
    await runYearlyCleanup(date); // 指定された日付でクリーンアップを実行
    res.json({ message: '年次クリーンアップが正常に実行されました' });
  } catch (err) {
    console.error('年次クリーンアップエラー:', err);
    res.status(500).json({ message: '年次クリーンアップに失敗しました' });
  }
});

module.exports = router;


このファイルもカスタマイズの必要はほぼありません。

ただし、デフォルトではクリックデータを60日間保存するように設定しています(下記参照)。

// clicksテーブルのクリーンアップジョブ
const cleanupJob = new CronJob('0 0 * * *', async () => {
  try {
    const query = `
      DELETE FROM clicks
      WHERE conversion = 0 AND click_time < NOW() - INTERVAL 60 DAY
    `; // 60日以上前でコンバージョンが0のクリックデータを削除するクエリ

これを例えば90日間に変更したい場合は、以下のように記述してください。

// clicksテーブルのクリーンアップジョブ
const cleanupJob = new CronJob('0 0 * * *', async () => {
  try {
    const query = `
      DELETE FROM clicks
      WHERE conversion = 0 AND click_time < NOW() - INTERVAL 90 DAY
    `; // 90日以上前でコンバージョンが0のクリックデータを削除するクエリ

(両コードの違いは、60を90に変えた箇所だけです)

余談ですが、プログラムコードの違いを確認するときは、https://difff.jp/のサイトを利用すれば、どんなに長いコードでも差分(=違い)を簡単にチェックできます。

■ server.jsファイル

続いて案内するファイルは、ウェブアプリケーションのサーバー側メインファイルです。
これはかなり長いコードになりますが、ユーザーごとに必要なカスタマイズ箇所は、ほんの一部だけで難しいことはありません。
ただし、ここから先はシステムの根幹となるため、有料とさせていただきます。

(注)本コンテンツのnoteでの販売は終了しました。
お買い求めの方は(株)サウスフィールドプランニングのホームページ内で案内しております。