リアルタイム投票
模擬店や展示部門の人気投票を、不正無くオンラインで投票可能。設定済みの投票終了時間を過ぎると自動的に投票不可能となり、0秒で集計が完了。
使い方
参加者向け
トップページから投票ページへのリンクカード
import VoteStatus from "@/features/vote/components/VoteStatus";
<VoteStatus />投票ページでのカード
import { VoteList } from "@/features/vote/components/VoteList";
import { useVoteData } from "@/features/vote/hooks/useVoteData";
const {
votingId,
timeStatus,
handleVote,
filteredTargets,
currentVotedId,
votedItems,
} = useVoteData();
---
<VoteList
targets={filteredTargets}
currentVotedId={currentVotedId}
votingId={votingId}
canVote={timeStatus.canVote}
onVote={handleVote}
loading={loading}
/>全体管理者向け
import VoteAdmin from "@/features/vote/components/VoteAdmin";
<VoteAdmin filterCategory="s" />API src/features/vote/api.ts
| API 関数 | 引数 / ペイロード | 役割 |
|---|---|---|
getVoterId() | なし | LocalStorageとCookieを同期して匿名UUIDを取得・生成 |
submitVote(targetId, category) | targetId: stringcategory: string | クライアント側レートリミット判定後、Supabase RPC で投票処理 |
getMyVotes(voterId) | voterId: string | 指定したUUIDの投票履歴 (部門とターゲットID) を取得 |
clearVoterId() | なし | LocalStorage および Cookie に保存された投票者 UUID と投票履歴を消去 |
getVoteResults() | なし | 全部門の圧縮済み順位・得票数を取得 get_vote_results_compressed |
getVoteResultsSummary() | なし | 管理者専用 : 部門ごとの総投票数や集計サマリーを取得 get_vote_results_summary |
exportVoteData() | なし | 管理者専用 : 生の投票ログをエクスポート |
auditSuspiciousVotes() | なし | 管理者専用 : 不審な連打投票・組織票の痕跡を検出 |
コード
1. 多重投票の抑制と不正対策
FesTime では、参加者がアプリインストールやログインなしで手軽に参加できるよう、匿名型の投票システムを採用しています。しかし、イベントの公平性を保つため、バックグラウンドで厳格な不正対策を講じています。
- 匿名識別子の発行と永続化: 端末・ブラウザ単位で独自の識別子を安全に発行し、複数のストレージメカニズムを組み合わせてセッションが切れても状態を強固に保持する仕組みを導入しています。
- レートリミットと連打防止: クライアント側およびネットワーク層で短時間の連続リクエストを監視し、機械的なアクセスや異常な連打を即座に遮断します。
- データベース層での保護: Supabase 側の Row Level Security (RLS) やサーバーサイド関数においてもバリデーションを行い、改ざんされたリクエストや不正なデータ書き込みを防止しています。
2. 軽量・高速集計 RPC get_vote_results_compressed
万単位の投票レコードをそのまま取得するのではなく、PostgreSQL 側でリアルタイムに GROUP BY 集計し、キー名を1文字 (c: category, i: id, v: votes) に圧縮して 1KB 未満の最小 JSON で返却します。
CREATE OR REPLACE FUNCTION get_vote_results_compressed()
RETURNS json
LANGUAGE sql
SECURITY DEFINER
AS $$
SELECT coalesce(json_agg(
json_build_object(
'c', category,
'i', target_id,
'v', vote_count
)
), '[]'::json)
FROM (
SELECT category, target_id, count(*)::int AS vote_count
FROM votes
GROUP BY category, target_id
ORDER BY category, vote_count DESC
) sub;
$$;データベーススキーマ
CREATE TABLE votes (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
voter_id TEXT NOT NULL,
category TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()),
-- 同一人物による同一部門への多重投票をDBレベルでブロック
UNIQUE(voter_id, category)
);