0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js + Drizzle ORMでTrust Score(信頼性スコア)を実装する — PageRankに着想を得たユーザー信頼度の数値化

0
Posted at

はじめに

プロフィールリンクサービス myna.me で、ユーザーの信頼性を0〜100で数値化する Trust Score(トラストスコア) を実装しました。

GoogleのPageRankからインスピレーションを得たスコアリングアルゴリズムで、「OAuth認証の数と種類」「プロフィール完成度」「アカウント成熟度」「レピュテーション」の4軸から信頼度を算出します。

この記事では、スコア計算のアルゴリズム設計、Drizzle ORMでのキャッシュ実装、バッジティア判定、SVGバッジAPIの実装について解説します。

Trust Scoreとは

Trust Scoreは、プロフィールの信頼性を複合的な指標で数値化するシステムです。

スコア ティア バッジ色
0-19 (なし)
20-39 Bronze #CD7F32
40-59 Silver #A0A0A0
60-79 Gold #FFD700
80-100 Diamond #60A5FA

単に「SNSを何個接続しているか」ではなく、認証方式の多様性プロフィールの充実度時間経過による成熟度他ユーザーからの評価を統合的に評価します。

スコア計算アルゴリズム (v3)

Trust Scoreは4つのカテゴリで構成され、合計0〜100点です。

Trust Score (0-100)
├── 本人認証スコア (0-40点)
├── プロフィール完成度 (0-15点)
├── アカウント成熟度 (0-15点)
└── レピュテーション (0-30点)

1. 本人認証スコア(0-40点)

OAuth認証と認証コード検証で接続されたアカウントの数と多様性を評価します。

function calculateVerificationScore(
  accounts: ConnectedAccount[]
): number {
  // 認証方式ごとのベーススコア
  const BASE_SCORES = {
    oauth: 10,        // OAuth認証: 10点
    verification: 9,  // 認証コード: 9点
  };
  
  // 同一タイプの複数接続は逓減
  // 例: OAuth 1個目=10点, 2個目=7.7点, 3個目=6.25点...
  const typeGroups = groupBy(accounts, a => a.connectType);
  
  let score = 0;
  for (const [type, group] of Object.entries(typeGroups)) {
    const base = BASE_SCORES[type] || 0;
    group.forEach((_, index) => {
      score += base / (1 + 0.3 * index);
    });
  }
  
  // 認証方式の多様性ボーナス
  // OAuth + 認証コードの両方を使っていれば +20%
  const uniqueTypes = new Set(
    accounts.map(a => a.connectType)
  );
  if (uniqueTypes.size >= 2) {
    score *= 1.2;
  }
  
  return Math.min(40, Math.round(score));
}

設計のポイント:

  • 同じタイプの認証を増やしても逓減する(1/(1+0.3*n)
  • 複数の認証方式を使うと多様性ボーナス(+20%)
  • 上限40点でキャップ

2. プロフィール完成度(0-15点)

プロフィールの充実度を複数の観点から評価します。

function calculateProfileScore(user: User): number {
  let score = 0;
  
  if (user.avatarUrl) score += 3;           // アバター画像
  if (user.bio && user.bio.length >= 20) score += 3;  // bio 20字以上
  if (user.bioEn) score += 2;              // 多言語bio
  if (user.subtitle) score += 2;           // 肩書き
  if (timelineCount >= 2) score += 2;      // タイムライン2件以上
  if (user.showCertificate) score += 1;    // 証明書有効
  if (user.location) score += 1;           // 位置情報
  if (user.showQr) score += 1;            // QR有効
  
  return Math.min(15, score);
}

3. アカウント成熟度(0-15点)

アカウント作成からの経過日数を 指数関数的減衰曲線 で評価します。

function calculateMaturityScore(createdAt: Date): number {
  const days = daysSince(createdAt);
  // 指数関数的減衰: 15 * (1 - e^(-days/180))
  // 180日で約63%(9.5点)、1年で約87%(13点)に到達
  return Math.round(15 * (1 - Math.exp(-days / 180)));
}

ステップ関数(30日=5点、90日=10点...)ではなく指数関数を使うことで、新規ユーザーでも日々スコアが上がる 実感を得られます。

4. レピュテーション(0-30点)

他ユーザーからの評価を時間重み付きで算出します。

function calculateReputationScore(metrics: UserMetrics): number {
  // 時間バケットの重み
  const TIME_WEIGHTS = {
    recent: 1.0,    // 30日以内
    medium: 0.7,    // 90日以内
    old: 0.4,       // 180日以内
    ancient: 0.2,   // それ以前
  };
  
  // 各指標にlog2圧縮 + 時間重みを適用
  const likeScore = calcWeighted(metrics.likes, 8);        // 0-8
  const bookmarkScore = calcWeighted(metrics.bookmarks, 7); // 0-7
  const connectionScore = calcWeighted(metrics.connections, 8); // 0-8
  const engagementScore = calcEngagement(metrics, 4);       // 0-4
  const messageScore = calcWeighted(metrics.messages, 3);   // 0-3
  
  return Math.min(30, Math.round(
    likeScore + bookmarkScore + connectionScore + 
    engagementScore + messageScore
  ));
}

設計思想:

  • log2 圧縮で、最初の数件は大きく効くが大量に集めても上限に収束
  • 最近のアクティビティほど高く評価(時間重み付き)
  • エンゲージメント率(クリック/PV)はPV≥10の場合のみ有効化

Drizzle ORMでのキャッシュ実装

Trust Scoreの計算には複数テーブルのJOINが必要で、毎リクエスト計算するとコストが高いです。そこで usersテーブルにキャッシュカラム を持たせています。

スキーマ定義

// schema.ts (Drizzle ORM)
export const users = pgTable('users', {
  id: text('id').primaryKey(),
  // ... 他のカラム
  
  // Trust Score キャッシュ
  trustScore: integer('trust_score').default(0),
  trustScoreTier: varchar('trust_score_tier', { length: 10 }),
  trustScoreUpdatedAt: timestamp('trust_score_updated_at'),
  showTrustScore: boolean('show_trust_score').default(true),
});

fire-and-forget更新パターン

export async function calculateTrustScore(
  userId: string
): Promise<TrustScoreResult> {
  // 1. 全データを並列取得
  const [user, accounts, likes, bookmarks, connections, views] = 
    await Promise.all([
      db.query.users.findFirst({ where: eq(users.id, userId) }),
      db.query.connectedAccounts.findMany({ where: eq(connectedAccounts.userId, userId) }),
      db.query.likes.findMany({ where: eq(likes.profileUserId, userId) }),
      db.query.bookmarks.findMany({ where: eq(bookmarks.profileUserId, userId) }),
      db.query.connections.findMany({ /* ... */ }),
      db.query.profileViews.findMany({ where: eq(profileViews.userId, userId) }),
    ]);
  
  // 2. 4カテゴリのスコアを計算
  const verification = calculateVerificationScore(accounts);
  const profile = calculateProfileScore(user);
  const maturity = calculateMaturityScore(user.createdAt);
  const reputation = calculateReputationScore({ likes, bookmarks, connections, views });
  
  const total = verification + profile + maturity + reputation;
  const tier = getTierFromScore(total);
  
  // 3. キャッシュを非同期更新(fire-and-forget)
  db.update(users)
    .set({
      trustScore: total,
      trustScoreTier: tier,
      trustScoreUpdatedAt: new Date(),
    })
    .where(eq(users.id, userId))
    .then(() => {}) // fire-and-forget
    .catch(console.error);
  
  return { total, tier, breakdown: { verification, profile, maturity, reputation } };
}

fire-and-forgetのメリット:

  • スコア計算結果はすぐ返し、DB更新は非同期
  • OG画像生成(Edge Runtime)ではキャッシュカラムから読み取り、APIコール不要
  • ユーザーのリクエストをブロックしない

SVGバッジAPI

Trust Scoreを外部サイトに埋め込めるSVGバッジAPIも実装しました。GitHubのREADMEなどに貼り付けることができます。

エンドポイント

GET /api/badge/[username]?type=trust&style=default

実装

// app/api/badge/[username]/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(
  req: NextRequest,
  { params }: { params: { username: string } }
) {
  // レート制限: 60req/min (IP単位)
  const rateLimitResult = await rateLimit(req, 60);
  if (!rateLimitResult.success) {
    return new NextResponse('Rate limit exceeded', { status: 429 });
  }
  
  const user = await db.query.users.findFirst({
    where: eq(users.username, params.username),
  });
  
  if (!user) {
    return new NextResponse('User not found', { status: 404 });
  }
  
  // キャッシュカラムからスコア取得(計算不要)
  const score = user.trustScore ?? 0;
  const tier = user.trustScoreTier ?? '';
  const tierColor = getTierColor(tier);
  
  // XSSエスケープしてSVG生成
  const svg = generateTrustBadgeSvg(
    escapeXml(user.username),
    score,
    escapeXml(tier),
    tierColor
  );
  
  return new NextResponse(svg, {
    headers: {
      'Content-Type': 'image/svg+xml',
      'Cache-Control': 'public, max-age=3600',
      'Cross-Origin-Resource-Policy': 'cross-origin',
    },
  });
}

function escapeXml(str: string): string {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;');
}

セキュリティ考慮

  • XSSエスケープ: ユーザー名やティア名をSVGに埋め込む前にXMLエスケープ
  • レート制限: IPアドレス単位のsliding windowで60req/min
  • CORS: Cross-Origin-Resource-Policy: cross-origin で外部サイトからの読み込みを許可
  • キャッシュ: 1時間のpublicキャッシュでCDN最適化

ティア判定のシンプルな実装

type TrustTier = '' | 'Bronze' | 'Silver' | 'Gold' | 'Diamond';

function getTierFromScore(score: number): TrustTier {
  if (score >= 80) return 'Diamond';
  if (score >= 60) return 'Gold';
  if (score >= 40) return 'Silver';
  if (score >= 20) return 'Bronze';
  return '';
}

function getTierColor(tier: TrustTier): string {
  const colors: Record<TrustTier, string> = {
    '': '#6B7280',
    Bronze: '#CD7F32',
    Silver: '#A0A0A0',
    Gold: '#FFD700',
    Diamond: '#60A5FA',
  };
  return colors[tier];
}

Pro会員とスコアの独立性

重要な設計判断として、Pro会員であることはTrust Scoreに影響しない ようにしています。

信頼性 ≠ 課金状態

Trust Scoreはあくまで「本人認証」「プロフィール充実度」「アカウント成熟度」「他者からの評価」で算出され、お金で買えるものではありません。これにより、スコアの信頼性自体を担保しています。

まとめ

Trust Scoreの実装で重要だったポイントをまとめます。

  1. PageRankの思想を応用: 単純なカウントではなく、逓減関数・多様性ボーナス・時間重みで多角的に評価
  2. 指数関数的減衰曲線: ステップ関数より自然な成長体験を提供
  3. fire-and-forgetキャッシュ: 計算結果をDBにキャッシュし、OG画像生成などでの再計算を回避
  4. SVGバッジAPI: XSSエスケープ + レート制限 + CORS設定で安全に外部公開
  5. 課金との独立性: Trust Scoreの信頼性を担保するための重要な設計判断

信頼性の数値化は正解のない問題ですが、アルゴリズムをバージョニング(現在v3)しながら改善を続けています。


myna.meは現在ベータ版として公開中です。OAuth認証で安全なプロフィールリンクを作成できます。

2026年2月25日に Product Hunt でのローンチを予定しています。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?