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?

License System Day 23: 追加機能の実装アイデア

Last updated at Posted at 2025-12-22

🎄 科学と神々株式会社 アドベントカレンダー 2025

License System Day 23: 追加機能の実装アイデア


📖 今日のテーマ

今日は、ライセンスシステムの機能拡張を学びます。

ライセンス譲渡、サブスクリプション自動更新、管理者ダッシュボード、2要素認証、使用量分析、オフラインライセンス、マルチテナント対応まで、実用的な拡張機能を解説します。


🎯 機能拡張ロードマップ

優先度別機能一覧

高優先度(MVP+機能):
✅ ライセンス譲渡
✅ サブスクリプション自動更新
✅ 2要素認証(TOTP)
✅ 使用量分析

中優先度(商用化機能):
⏳ 管理者ダッシュボード
⏳ Webhook通知システム
⏳ オフラインライセンス対応

低優先度(エンタープライズ機能):
🔜 マルチテナント対応
🔜 SSO統合(SAML/OAuth2)
🔜 コンプライアンスレポート

🔄 ライセンス譲渡機能

API仕様

# server/src/license_transfer.nim
import std/[json, options, times]
import ./[database_postgresql, crypto]

type
  TransferRequest* = object
    fromUserId*: string
    toEmail*: string
    licenseId*: string
    reason*: string

proc initiateTransfer*(
    db: PostgresDatabase,
    request: TransferRequest
): JsonNode =
  ## ライセンス譲渡を開始

  # 1. 譲渡元ライセンスの確認
  let license = db.getLicense(request.licenseId)
  if license.isNone:
    return %*{"error": "License not found"}

  if license.get.userId != request.fromUserId:
    return %*{"error": "Unauthorized"}

  # 2. 譲渡先ユーザーの確認
  var toUser = db.getUserByEmail(request.toEmail)
  if toUser.isNone:
    # 新規ユーザーを招待
    let userId = db.createUser(request.toEmail, "")  # 仮パスワード
    toUser = db.getUserByEmail(request.toEmail)

  # 3. 譲渡トークン生成(24時間有効)
  let transferToken = generateUUID()
  db.exec(sql"""
    INSERT INTO transfer_requests (
      transfer_token,
      from_user_id,
      to_user_id,
      license_id,
      reason,
      expires_at
    ) VALUES ($1, $2, $3, $4, $5, $6)
  """,
    transferToken,
    request.fromUserId,
    toUser.get.userId,
    request.licenseId,
    request.reason,
    $(now() + initDuration(hours = 24))
  )

  # 4. 譲渡先ユーザーにメール送信(省略)

  return %*{
    "status": "pending",
    "transfer_token": transferToken,
    "to_email": request.toEmail,
    "expires_at": $(now() + initDuration(hours = 24))
  }

proc completeTransfer*(
    db: PostgresDatabase,
    transferToken: string
): JsonNode =
  ## ライセンス譲渡を完了

  # 1. 譲渡リクエスト取得
  let transfer = db.getRow(sql"""
    SELECT from_user_id, to_user_id, license_id, expires_at
    FROM transfer_requests
    WHERE transfer_token = $1 AND status = 'pending'
  """, transferToken)

  if transfer[0] == "":
    return %*{"error": "Invalid or expired transfer token"}

  # 2. 有効期限確認
  let expiresAt = parse(transfer[3], "yyyy-MM-dd HH:mm:ss")
  if now() > expiresAt:
    return %*{"error": "Transfer token has expired"}

  # 3. ライセンス所有者変更
  db.exec(sql"""
    UPDATE licenses
    SET user_id = $1, updated_at = datetime('now')
    WHERE license_id = $2
  """, transfer[1], transfer[2])

  # 4. 譲渡リクエストを完了状態に
  db.exec(sql"""
    UPDATE transfer_requests
    SET status = 'completed', completed_at = datetime('now')
    WHERE transfer_token = $1
  """, transferToken)

  return %*{
    "status": "completed",
    "license_id": transfer[2],
    "new_owner": transfer[1]
  }

# APIエンドポイント
routes:
  post "/api/v1/license/transfer":
    ## ライセンス譲渡開始

    let token = request.headers["X-Activation-Key"]
    let claims = cryptoService.verifyJWT(token)

    if claims.isNone:
      resp Http401, %*{"error": "Unauthorized"}

    let userId = claims.get["user_id"].getStr()
    let body = parseJson(request.body)

    let transferRequest = TransferRequest(
      fromUserId: userId,
      toEmail: body["to_email"].getStr(),
      licenseId: body["license_id"].getStr(),
      reason: body.getOrDefault("reason").getStr("User requested transfer")
    )

    let result = initiateTransfer(db, transferRequest)
    resp Http200, result

  post "/api/v1/license/transfer/complete":
    ## ライセンス譲渡完了

    let body = parseJson(request.body)
    let transferToken = body["transfer_token"].getStr()

    let result = completeTransfer(db, transferToken)

    if result.hasKey("error"):
      resp Http400, result
    else:
      resp Http200, result

💳 サブスクリプション自動更新(Stripe連携)

Stripe Webhook実装

# server/src/stripe_integration.nim
import std/[json, httpclient, asyncdispatch, times]

type
  StripeWebhook* = object
    event*: string
    data*: JsonNode

proc handleStripeWebhook*(payload: string, signature: string): JsonNode =
  ## Stripe Webhook処理

  # 1. Stripe署名検証(セキュリティ)
  if not verifyStripeSignature(payload, signature):
    return %*{"error": "Invalid signature"}

  let event = parseJson(payload)
  let eventType = event["type"].getStr()

  case eventType
  of "invoice.payment_succeeded":
    handlePaymentSuccess(event["data"]["object"])
  of "invoice.payment_failed":
    handlePaymentFailed(event["data"]["object"])
  of "customer.subscription.deleted":
    handleSubscriptionDeleted(event["data"]["object"])
  of "customer.subscription.updated":
    handleSubscriptionUpdated(event["data"]["object"])
  else:
    echo "Unhandled event type: ", eventType
    return %*{"status": "ignored"}

proc handlePaymentSuccess(invoice: JsonNode): JsonNode =
  ## 支払い成功時の処理

  let customerId = invoice["customer"].getStr()
  let subscriptionId = invoice["subscription"].getStr()

  # カスタマーIDからユーザーを取得
  let user = db.getUserByStripeCustomerId(customerId)

  if user.isSome:
    # サブスクリプション期間を延長
    db.exec(sql"""
      UPDATE subscriptions
      SET end_date = datetime('now', '+30 days'),
          status = 'active',
          updated_at = datetime('now')
      WHERE user_id = $1 AND stripe_subscription_id = $2
    """, user.get.userId, subscriptionId)

    echo "✅ Subscription renewed for user: ", user.get.email

  return %*{"status": "success"}

proc handlePaymentFailed(invoice: JsonNode): JsonNode =
  ## 支払い失敗時の処理

  let customerId = invoice["customer"].getStr()
  let user = db.getUserByStripeCustomerId(customerId)

  if user.isSome:
    # サブスクリプションを一時停止
    db.exec(sql"""
      UPDATE subscriptions
      SET status = 'past_due',
          updated_at = datetime('now')
      WHERE user_id = $1
    """, user.get.userId)

    # ユーザーにメール通知(省略)
    echo "⚠️  Payment failed for user: ", user.get.email

  return %*{"status": "handled"}

# Webhookエンドポイント
routes:
  post "/webhooks/stripe":
    ## Stripe Webhook受信

    let payload = request.body
    let signature = request.headers["Stripe-Signature"]

    let result = handleStripeWebhook(payload, signature)
    resp Http200, result

🔐 2要素認証(TOTP)

TOTP実装

# server/src/two_factor_auth.nim
import std/[base64, times, hmac, sha1, strutils]
import nimcrypto

type
  TwoFactorAuth* = ref object
    secret*: string

proc generateTOTPSecret*(): string =
  ## TOTP秘密鍵生成(Base32エンコード)

  var randomBytes: array[20, byte]
  randomBytes(randomBytes)

  result = encode(randomBytes).replace("=", "")

proc generateTOTP*(secret: string, timeStep: int = 30): string =
  ## TOTP生成(6桁)

  let currentTime = (getTime().toUnix() div timeStep).uint64

  # HMAC-SHA1計算
  var timeBytes: array[8, byte]
  for i in 0..<8:
    timeBytes[7 - i] = byte((currentTime shr (i * 8)) and 0xFF)

  let secretBytes = decode(secret)
  let hmacResult = hmac_sha1(secretBytes, timeBytes)

  # Dynamic truncation
  let offset = int(hmacResult[^1] and 0x0F)
  let code = (
    (int(hmacResult[offset]) and 0x7F) shl 24 or
    (int(hmacResult[offset + 1]) and 0xFF) shl 16 or
    (int(hmacResult[offset + 2]) and 0xFF) shl 8 or
    (int(hmacResult[offset + 3]) and 0xFF)
  ) mod 1_000_000

  result = ($code).align(6, '0')

proc verifyTOTP*(secret, code: string, window: int = 1): bool =
  ## TOTP検証(±1タイムステップ許容)

  for offset in -window..window:
    let timeStep = 30
    let adjustedTime = getTime().toUnix() div timeStep + offset
    let expectedCode = generateTOTP(secret, timeStep)

    if code == expectedCode:
      return true

  return false

# 2FA登録エンドポイント
routes:
  post "/api/v1/2fa/register":
    ## 2FA登録(QRコード生成)

    let token = request.headers["X-Activation-Key"]
    let claims = cryptoService.verifyJWT(token)

    if claims.isNone:
      resp Http401, %*{"error": "Unauthorized"}

    let userId = claims.get["user_id"].getStr()
    let secret = generateTOTPSecret()

    # 秘密鍵をDBに保存(一時的)
    db.exec(sql"""
      UPDATE users
      SET totp_secret_temp = $1
      WHERE user_id = $2
    """, secret, userId)

    # QRコード用URL
    let qrUrl = "otpauth://totp/LicenseSystem:" & claims.get["email"].getStr() &
                "?secret=" & secret & "&issuer=LicenseSystem"

    resp Http200, %*{
      "secret": secret,
      "qr_url": qrUrl
    }

  post "/api/v1/2fa/verify":
    ## 2FA検証と有効化

    let token = request.headers["X-Activation-Key"]
    let claims = cryptoService.verifyJWT(token)

    if claims.isNone:
      resp Http401, %*{"error": "Unauthorized"}

    let userId = claims.get["user_id"].getStr()
    let body = parseJson(request.body)
    let code = body["code"].getStr()

    # 一時秘密鍵取得
    let secret = db.getValue(sql"""
      SELECT totp_secret_temp
      FROM users
      WHERE user_id = $1
    """, userId)

    # TOTP検証
    if verifyTOTP(secret, code):
      # 2FA有効化
      db.exec(sql"""
        UPDATE users
        SET totp_secret = $1,
            totp_secret_temp = NULL,
            two_factor_enabled = 1
        WHERE user_id = $2
      """, secret, userId)

      resp Http200, %*{"status": "enabled"}
    else:
      resp Http400, %*{"error": "Invalid code"}

📊 使用量分析・レポート

使用統計の収集

# server/src/usage_analytics.nim
import std/[json, times, db_postgres]

type
  UsageMetrics* = object
    userId*: string
    endpoint*: string
    requestCount*: int
    dataTransferred*: int  # bytes
    responseTime*: float  # milliseconds
    timestamp*: DateTime

proc recordUsageMetrics*(db: PostgresDatabase, metrics: UsageMetrics) =
  ## 使用量メトリクス記録

  db.exec(sql"""
    INSERT INTO usage_metrics (
      user_id,
      endpoint,
      request_count,
      data_transferred,
      response_time,
      timestamp
    ) VALUES ($1, $2, $3, $4, $5, $6)
  """,
    metrics.userId,
    metrics.endpoint,
    metrics.requestCount,
    metrics.dataTransferred,
    metrics.responseTime,
    $metrics.timestamp
  )

proc getUsageReport*(
    db: PostgresDatabase,
    userId: string,
    startDate, endDate: DateTime
): JsonNode =
  ## 使用量レポート生成

  let rows = db.getAllRows(sql"""
    SELECT
      endpoint,
      SUM(request_count) AS total_requests,
      SUM(data_transferred) AS total_data,
      AVG(response_time) AS avg_response_time,
      DATE(timestamp) AS day
    FROM usage_metrics
    WHERE user_id = $1
      AND timestamp BETWEEN $2 AND $3
    GROUP BY endpoint, DATE(timestamp)
    ORDER BY day DESC
  """, userId, $startDate, $endDate)

  var report = newJArray()
  for row in rows:
    report.add(%*{
      "endpoint": row[0],
      "requests": parseInt(row[1]),
      "data_mb": parseFloat(row[2]) / 1_000_000.0,
      "avg_response_ms": parseFloat(row[3]),
      "date": row[4]
    })

  return %*{
    "user_id": userId,
    "start_date": $startDate,
    "end_date": $endDate,
    "metrics": report
  }

# 使用量レポートエンドポイント
routes:
  get "/api/v1/usage/report":
    ## 使用量レポート取得

    let token = request.headers["X-Activation-Key"]
    let claims = cryptoService.verifyJWT(token)

    if claims.isNone:
      resp Http401, %*{"error": "Unauthorized"}

    let userId = claims.get["user_id"].getStr()
    let startDate = parse(request.params["start_date"], "yyyy-MM-dd")
    let endDate = parse(request.params["end_date"], "yyyy-MM-dd")

    let report = getUsageReport(db, userId, startDate, endDate)
    resp Http200, report

💻 管理者ダッシュボード

管理者API

# server/src/admin_api.nim
import std/[json, db_postgres, times]

proc getSystemStats*(db: PostgresDatabase): JsonNode =
  ## システム全体の統計

  let totalUsers = db.getValue(sql"SELECT COUNT(*) FROM users").parseInt()
  let activeSubscriptions = db.getValue(sql"""
    SELECT COUNT(*) FROM subscriptions WHERE status = 'active'
  """).parseInt()
  let totalRevenue = db.getValue(sql"""
    SELECT SUM(amount) FROM payments WHERE status = 'succeeded'
  """).parseFloat()

  return %*{
    "total_users": totalUsers,
    "active_subscriptions": activeSubscriptions,
    "total_revenue": totalRevenue,
    "timestamp": $now()
  }

proc getRecentActivations*(db: PostgresDatabase, limit: int = 100): JsonNode =
  ## 最近のライセンス認証

  let rows = db.getAllRows(sql"""
    SELECT
      l.license_id,
      u.email,
      l.client_id,
      l.activated_at,
      s.plan_type
    FROM licenses l
    JOIN users u ON l.user_id = u.user_id
    JOIN subscriptions s ON l.subscription_id = s.subscription_id
    ORDER BY l.activated_at DESC
    LIMIT $1
  """, limit)

  var activations = newJArray()
  for row in rows:
    activations.add(%*{
      "license_id": row[0],
      "email": row[1],
      "client_id": row[2],
      "activated_at": row[3],
      "plan_type": row[4]
    })

  return %*{"activations": activations}

# 管理者エンドポイント(認証必須)
routes:
  get "/api/v1/admin/stats":
    ## システム統計(管理者のみ)

    let token = request.headers["X-Admin-Token"]
    if not verifyAdminToken(token):
      resp Http403, %*{"error": "Forbidden"}

    let stats = getSystemStats(db)
    resp Http200, stats

  get "/api/v1/admin/activations":
    ## 最近のライセンス認証一覧

    let token = request.headers["X-Admin-Token"]
    if not verifyAdminToken(token):
      resp Http403, %*{"error": "Forbidden"}

    let activations = getRecentActivations(db, limit = 100)
    resp Http200, activations

🌟 まとめ

追加機能の要点:

  1. ライセンス譲渡

    • 譲渡トークン生成
    • メール確認フロー
    • 所有者変更処理
  2. サブスクリプション自動更新

    • Stripe Webhook統合
    • 支払い成功/失敗処理
    • 自動期間延長
  3. 2要素認証

    • TOTP実装
    • QRコード生成
    • 検証ロジック
  4. 使用量分析

    • メトリクス収集
    • レポート生成
    • データ可視化
  5. 管理者ダッシュボード

    • システム統計
    • ユーザー管理
    • リアルタイム監視

前回: Day 22: 商用化への道 - スケーラビリティ
次回: Day 24: トラブルシューティングとFAQ

Happy Learning! 🎉

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?