🎄 科学と神々株式会社 アドベントカレンダー 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
🌟 まとめ
追加機能の要点:
-
ライセンス譲渡
- 譲渡トークン生成
- メール確認フロー
- 所有者変更処理
-
サブスクリプション自動更新
- Stripe Webhook統合
- 支払い成功/失敗処理
- 自動期間延長
-
2要素認証
- TOTP実装
- QRコード生成
- 検証ロジック
-
使用量分析
- メトリクス収集
- レポート生成
- データ可視化
-
管理者ダッシュボード
- システム統計
- ユーザー管理
- リアルタイム監視
前回: Day 22: 商用化への道 - スケーラビリティ
次回: Day 24: トラブルシューティングとFAQ
Happy Learning! 🎉