マルチテナントコンテンツ管理: バージョン管理と SCORM
大手研修会社は 500 社の顧客企業にサービスを提供しており、それぞれが独自のサービスを提供しています。 従業員、そのビジュアルアイデンティティ、およびコンプライアンス要件。 eラーニング コンテンツ プロバイダーはポリシー フォームを更新する必要があります セキュリティ (毎年変更されます) を変更し、その変更を 500 人の顧客全員に広めます 何もしなくても即座に。お客様のとき 独自のロゴと例を使用してフォームをカスタマイズしたい場合は、次のことができる必要があります。 自動更新を中断せずに実行してください。
これが問題です マルチテナントのコンテンツ管理 エドテック向け: 共有およびパーソナライズされたコンテンツの管理、適切なバージョン管理、配布 効率的にコンプライアンスを確保し、 スクロール (共有可能なコンテンツ オブジェクト参照モデル)、企業の e ラーニング パッケージの最も普及している標準です。
この記事では、データ構造からコンテンツまでの完全なシステムを構築します。 テナントごとのオーバーライドを備えたマルチテナント、パッケージのセマンティック バージョニング、 サードパーティ LMS と通信するために SCORM 準拠のエンドポイントに接続します。 効率的なグローバル配信のための CDN 戦略まで。
この記事で学べること
- テナントごとのカスタマイズを備えた共有コンテンツ用のマルチテナント データ モデル
- e ラーニング コンテンツ パッケージのセマンティック バージョニング (semver)
- SCORM 1.2 と SCORM 2004: 違い、構造、通信 API
- マルチテナント SCORM パッケージの集中ホスティングと分散ホスティング
- コンテンツのパッケージ化: ZIP、imsmanifest.xml マニフェストおよびファイル構造
- 高度な追跡のための SCORM API JavaScript ラッパー
- 低遅延のグローバル配信のための CDN 戦略
- SCORM から xAPI への移行: いつ、そしてなぜ
1. コンテンツのマルチテナント データ モデル
マルチテナント コンテンツ管理の課題は、次の 2 つの相反するニーズのバランスを取ることです。 共有 (更新されたコンテンツはすべてのテナントに反映されます) e カスタマイズ (各テナントは特定の項目をオーバーライドできます)。 モデルaを使用します 3つのレベル: グローバルコンテンツ (マスター)、 カテゴリのオーバーライド (同様の特性を持つテナントのグループ) とオーバーライド テナント固有。
# models/content.py
from dataclasses import dataclass, field
from typing import Optional, Dict, List, Any
from datetime import datetime
from enum import Enum
class ContentStatus(Enum):
DRAFT = "draft"
REVIEW = "review"
PUBLISHED = "published"
DEPRECATED = "deprecated"
ARCHIVED = "archived"
class SCORMVersion(Enum):
SCORM_12 = "1.2"
SCORM_2004_3 = "2004_3rd"
SCORM_2004_4 = "2004_4th"
XAPI = "xapi"
@dataclass
class ContentVersion:
"""Una versione specifica di un pacchetto di contenuto."""
version_id: str
content_id: str
semver: str # "1.2.3" (MAJOR.MINOR.PATCH)
status: ContentStatus
scorm_version: SCORMVersion
title: Dict[str, str] # {"it-IT": "...", "en-US": "..."} - multilingue
description: Dict[str, str]
# Path nel bucket S3/Cloud Storage al pacchetto ZIP
package_path: str
# Hash SHA256 del pacchetto per integrity check
package_hash: str
package_size_bytes: int
# URL del manifest per accesso diretto
manifest_url: str
created_at: datetime
created_by: str
changelog: str # Cosa e cambiato rispetto alla versione precedente
# Metadati curriculum
estimated_duration_minutes: int
topics: List[str]
prerequisites: List[str] # IDs di contenuti prerequisito
difficulty: int # 1-5
@dataclass
class ContentMaster:
"""Il contenuto 'master' condiviso tra tutti i tenant."""
content_id: str
category_id: str
global_id: str # Identificatore universale (es: "compliance-gdpr-intro")
current_version: str # Semver della versione corrente pubblicata
versions: List[ContentVersion] = field(default_factory=list)
auto_update_policy: str = "minor" # "none", "patch", "minor", "major"
tags: List[str] = field(default_factory=list)
@dataclass
class TenantContentOverride:
"""Override specifico per tenant di un contenuto master."""
override_id: str
tenant_id: str
content_id: str # Riferimento al ContentMaster
version_locked: Optional[str] = None # Se impostato, questo tenant usa sempre questa versione
# Personalizzazioni UI
custom_logo_url: Optional[str] = None
custom_colors: Optional[Dict[str, str]] = None # {"primary": "#..."}
custom_css_url: Optional[str] = None
# Override testi (es. esempi locali al posto di quelli globali)
text_overrides: Dict[str, str] = field(default_factory=dict)
# Slides o moduli da nascondere per questo tenant
hidden_modules: List[str] = field(default_factory=list)
# Moduli aggiuntivi solo per questo tenant
additional_modules: List[str] = field(default_factory=list)
enabled: bool = True
created_at: datetime = field(default_factory=datetime.utcnow)
updated_at: datetime = field(default_factory=datetime.utcnow)
@dataclass
class TenantContentAssignment:
"""Assegnazione di un contenuto a un tenant, con configurazione."""
assignment_id: str
tenant_id: str
content_id: str
effective_version: str # Versione effettivamente usata (dopo risoluzione override)
required: bool = False # Formazione obbligatoria?
deadline: Optional[datetime] = None
completion_certificate: bool = False
passing_score_percent: int = 70
max_attempts: int = 3
2. セマンティック バージョニングと伝播
使ってみましょう セマンティック バージョニング (複数) 内容: MAJOR.MINOR.PATCH。 パッチ = 修正 (タイプミス、バグ): すべてのテナントに自動的に反映されます。 ロックされたバージョンを持たないもの。 マイナー = 新しいオプション セクション、改善: テナント管理者への通知とともに反映されます。 選考科目 = カリキュラムの根本的な変更: テナントごとの明示的な承認が必要です。
# services/content_version_manager.py
from dataclasses import dataclass
from typing import List, Optional, Tuple
import semver
from datetime import datetime
@dataclass
class VersionBump:
content_id: str
from_version: str
to_version: str
bump_type: str # "major", "minor", "patch"
changelog: str
affected_tenants: List[str] # Tenant che riceveranno l'aggiornamento automatico
notify_tenants: List[str] # Tenant da notificare ma non aggiornare automaticamente
require_approval: List[str] # Tenant che richiedono approvazione esplicita
class ContentVersionManager:
def __init__(self, db, notification_service, event_bus):
self.db = db
self.notifications = notification_service
self.events = event_bus
async def publish_new_version(
self,
content_id: str,
new_version: str,
changelog: str,
package_path: str,
) -> VersionBump:
"""Pubblica una nuova versione e gestisce la propagazione ai tenant."""
content = await self._get_content(content_id)
current = content.current_version
bump_type = self._classify_bump(current, new_version)
# Recupera tutti i tenant che hanno questo contenuto
assignments = await self._get_tenant_assignments(content_id)
overrides = await self._get_tenant_overrides(content_id)
# Classifica i tenant per politica di aggiornamento
auto_update = []
notify_only = []
require_approval = []
for assignment in assignments:
override = overrides.get(assignment.tenant_id)
# Se la versione e bloccata: non aggiornare
if override and override.version_locked:
continue
# Applica la politica del contenuto master
if bump_type == "patch":
auto_update.append(assignment.tenant_id)
elif bump_type == "minor":
if content.auto_update_policy in ("minor", "major"):
auto_update.append(assignment.tenant_id)
else:
notify_only.append(assignment.tenant_id)
else: # major
if content.auto_update_policy == "major":
auto_update.append(assignment.tenant_id)
else:
require_approval.append(assignment.tenant_id)
# Esegui aggiornamenti automatici
for tenant_id in auto_update:
await self._apply_version_to_tenant(tenant_id, content_id, new_version)
# Notifiche
if notify_only:
await self.notifications.send_bulk(
tenant_ids=notify_only,
subject=f"Disponibile nuova versione: {content.global_id} v{new_version}",
body=f"Una nuova versione minore e disponibile.\nCambiamenti: {changelog}\nApprovazione manuale richiesta.",
action_url=f"/admin/content/{content_id}/versions/{new_version}",
)
if require_approval:
await self._create_approval_requests(require_approval, content_id, new_version, changelog)
# Aggiorna versione corrente del master
await self.db.execute(
"UPDATE content_masters SET current_version = :v WHERE content_id = :cid",
{"v": new_version, "cid": content_id},
)
await self.db.commit()
# Pubblica evento
await self.events.publish({
"type": "content.version.published",
"content_id": content_id,
"version": new_version,
"bump_type": bump_type,
"auto_updated_tenants": len(auto_update),
})
return VersionBump(
content_id=content_id,
from_version=current,
to_version=new_version,
bump_type=bump_type,
changelog=changelog,
affected_tenants=auto_update,
notify_tenants=notify_only,
require_approval=require_approval,
)
def _classify_bump(self, current: str, new_version: str) -> str:
try:
v_current = semver.VersionInfo.parse(current)
v_new = semver.VersionInfo.parse(new_version)
if v_new.major > v_current.major:
return "major"
if v_new.minor > v_current.minor:
return "minor"
return "patch"
except ValueError:
return "major" # Default conservativo se parsing fallisce
async def _apply_version_to_tenant(self, tenant_id: str, content_id: str, version: str) -> None:
await self.db.execute(
"""UPDATE tenant_content_assignments
SET effective_version = :v, updated_at = NOW()
WHERE tenant_id = :tid AND content_id = :cid""",
{"v": version, "tid": tenant_id, "cid": content_id},
)
await self.db.commit()
3. SCORM とマニフェストの構造 imsmanifest.xml
SCORM は、正確な構造を持つ ZIP パッケージ形式を定義します。
ファイル imsmanifest.xml そしてエントリポイント: 説明します
コース構造(構成)、リソース(HTMLファイル、ビデオ、画像)
そしてメタデータ。このマニフェストをプログラムで生成して、
両方の SCORM バージョンの正確さとサポート。
# packaging/scorm_packager.py
import zipfile
import xml.etree.ElementTree as ET
from io import BytesIO
from dataclasses import dataclass
from typing import List, Optional
from pathlib import Path
import hashlib
@dataclass
class SCORMModule:
identifier: str # Univoco nel pacchetto
title: str
launch_url: str # Es: "content/module01/index.html"
scorm_type: str = "sco" # "sco" o "asset"
mastery_score: int = 70 # Punteggio minimo per superamento (%)
time_limit_minutes: Optional[int] = None
@dataclass
class SCORMPackageConfig:
course_id: str
course_title: str
course_description: str
scorm_version: str # "1.2" o "2004_4th"
language: str # "it-IT", "en-US"
modules: List[SCORMModule]
tenant_id: str
version: str
class SCORMPackager:
"""Genera pacchetti SCORM conformi allo standard ADL."""
SCORM_12_SCHEMA = "http://www.adlnet.org/xsd/adlcp_rootv1p2"
SCORM_2004_SCHEMA = "http://www.adlnet.org/xsd/adlcp_v1p3"
def package(self, config: SCORMPackageConfig, content_dir: Path) -> bytes:
"""
Genera un pacchetto SCORM ZIP con imsmanifest.xml.
content_dir: directory con i file HTML/CSS/JS del corso.
"""
zip_buffer = BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
# Aggiungi il manifest
manifest = self._generate_manifest(config)
zf.writestr('imsmanifest.xml', manifest)
# Aggiungi API wrapper SCORM JavaScript
scorm_api = self._get_scorm_api_js(config.scorm_version)
zf.writestr('scorm_api.js', scorm_api)
# Aggiungi tutti i file del corso
for file_path in content_dir.rglob('*'):
if file_path.is_file():
relative = file_path.relative_to(content_dir)
zf.write(file_path, str(relative))
return zip_buffer.getvalue()
def _generate_manifest(self, config: SCORMPackageConfig) -> str:
"""Genera il file imsmanifest.xml secondo lo standard SCORM."""
if config.scorm_version == "1.2":
return self._manifest_12(config)
return self._manifest_2004(config)
def _manifest_12(self, config: SCORMPackageConfig) -> str:
"""Manifest per SCORM 1.2."""
return f"""<?xml version="1.0" encoding="UTF-8"?>
<manifest identifier="{config.course_id}" version="1.0"
xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
xmlns:adlcp="{self.SCORM_12_SCHEMA}"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.imsproject.org/xsd/imscp_rootv1p1p2
imscp_rootv1p1p2.xsd {self.SCORM_12_SCHEMA} adlcp_rootv1p2.xsd">
<metadata>
<schema>ADL SCORM</schema>
<schemaversion>1.2</schemaversion>
</metadata>
<organizations default="ORG-{config.course_id}">
<organization identifier="ORG-{config.course_id}" structure="hierarchical">
{config.course_title}
{self._generate_items_12(config.modules)}
</organization>
</organizations>
<resources>
{self._generate_resources_12(config.modules)}
</resources>
</manifest>"""
def _generate_items_12(self, modules: List[SCORMModule]) -> str:
items = []
for module in modules:
mastery = f'<adlcp:masteryscore>{module.mastery_score}</adlcp:masteryscore>' if module.scorm_type == 'sco' else ''
items.append(f"""
<item identifier="ITEM-{module.identifier}" identifierref="RES-{module.identifier}" isvisible="true">
{module.title}
{mastery}
</item>""")
return "".join(items)
def _generate_resources_12(self, modules: List[SCORMModule]) -> str:
resources = []
for module in modules:
resources.append(f"""
<resource identifier="RES-{module.identifier}" type="webcontent"
adlcp:scormtype="{module.scorm_type}" href="{module.launch_url}">
<file href="{module.launch_url}" />
</resource>""")
return "".join(resources)
def _get_scorm_api_js(self, version: str) -> str:
"""
SCORM API wrapper JavaScript per comunicazione con LMS.
Semplificato: in produzione usa scorm-again o pipwerks-scorm-api.
"""
if version == "1.2":
return """
// SCORM 1.2 API Wrapper - Simplified
var API = null;
function findAPI(win) {
var searchLimit = 7;
var currentWindow = win;
while ((currentWindow.API == null) && (currentWindow.parent != null) && (currentWindow.parent != currentWindow)) {
searchLimit--;
if (searchLimit <= 0) return null;
currentWindow = currentWindow.parent;
}
return currentWindow.API;
}
function getAPI() {
if (API == null) {
API = findAPI(window);
if (API == null && window.opener != null) {
API = findAPI(window.opener);
}
}
return API;
}
var SCORM = {
init: function() {
var api = getAPI();
if (api) return api.LMSInitialize("") === "true";
return false;
},
finish: function() {
var api = getAPI();
if (api) {
api.LMSCommit("");
return api.LMSFinish("") === "true";
}
return false;
},
getValue: function(element) {
var api = getAPI();
if (api) return api.LMSGetValue(element);
return "";
},
setValue: function(element, value) {
var api = getAPI();
if (api) return api.LMSSetValue(element, value) === "true";
return false;
},
commit: function() {
var api = getAPI();
if (api) return api.LMSCommit("") === "true";
return false;
},
setProgress: function(percent) {
this.setValue("cmi.core.score.raw", percent.toString());
if (percent >= 70) {
this.setValue("cmi.core.lesson_status", "passed");
} else {
this.setValue("cmi.core.lesson_status", "failed");
}
this.commit();
},
setCompleted: function() {
this.setValue("cmi.core.lesson_status", "completed");
this.commit();
}
};"""
return "// SCORM 2004 API wrapper"
4. グローバル配信に向けた CDN 戦略
SCORM パッケージには重いアセット (ビデオ、HD 画像) が含まれています。配布してください 単一のサーバーからの接続では、遠く離れた学生にとって待ち時間が長くなります。 私たちはマルチレベル CDN 戦略を使用します。 CloudFront/Cloudflare グローバル エッジ キャッシングの場合、ストレージはオン S3/クラウドストレージ オリジンやアクセス制御のための署名付き URL など。
# cdn/content_delivery.py
import boto3
from botocore.signers import CloudFrontSigner
import rsa
from datetime import datetime, timedelta
from typing import Optional
import json
import hashlib
class SCORMContentDelivery:
"""
Gestisce distribuzione sicura di pacchetti SCORM via CDN.
Usa URL firmati CloudFront per controllo accesso.
"""
def __init__(
self,
cloudfront_domain: str,
s3_bucket: str,
private_key_path: str,
key_pair_id: str,
):
self.cdn_domain = cloudfront_domain
self.s3_bucket = s3_bucket
self.key_pair_id = key_pair_id
self.s3_client = boto3.client('s3')
self.cf_client = boto3.client('cloudfront')
with open(private_key_path, 'rb') as f:
self._private_key = rsa.PrivateKey.load_pkcs1(f.read())
def get_signed_launch_url(
self,
content_id: str,
tenant_id: str,
version: str,
module_path: str,
expires_in_hours: int = 8,
) -> str:
"""
Genera un URL firmato CloudFront per un modulo SCORM specifico.
L'URL scade dopo expires_in_hours ore per sicurezza.
"""
s3_path = f"tenants/{tenant_id}/content/{content_id}/{version}/{module_path}"
cdn_url = f"https://{self.cdn_domain}/{s3_path}"
expire_date = datetime.utcnow() + timedelta(hours=expires_in_hours)
signer = CloudFrontSigner(self.key_pair_id, self._rsa_signer)
signed_url = signer.generate_presigned_url(
cdn_url,
date_less_than=expire_date,
)
return signed_url
def _rsa_signer(self, message: bytes) -> bytes:
return rsa.sign(message, self._private_key, 'SHA-1')
async def upload_package(
self,
content_id: str,
tenant_id: str,
version: str,
package_bytes: bytes,
) -> dict:
"""
Carica un pacchetto SCORM su S3 e invalida la cache CDN.
"""
prefix = f"tenants/{tenant_id}/content/{content_id}/{version}"
# Calcola hash per integrity check
package_hash = hashlib.sha256(package_bytes).hexdigest()
# Carica il pacchetto ZIP
package_key = f"{prefix}/package.zip"
self.s3_client.put_object(
Bucket=self.s3_bucket,
Key=package_key,
Body=package_bytes,
ContentType='application/zip',
Metadata={
'content-id': content_id,
'tenant-id': tenant_id,
'version': version,
'sha256': package_hash,
},
)
# Invalida cache CDN per questo contenuto
self.cf_client.create_invalidation(
DistributionId='YOUR_DISTRIBUTION_ID',
InvalidationBatch={
'Paths': {
'Quantity': 1,
'Items': [f"/tenants/{tenant_id}/content/{content_id}/{version}/*"],
},
'CallerReference': f"{content_id}-{version}-{int(datetime.utcnow().timestamp())}",
},
)
return {
"package_key": package_key,
"package_hash": package_hash,
"cdn_url": f"https://{self.cdn_domain}/{prefix}/",
}
async def generate_tenant_manifest_url(
self,
content_id: str,
tenant_id: str,
version: str,
) -> str:
"""URL del manifest SCORM per un tenant specifico."""
return self.get_signed_launch_url(
content_id=content_id,
tenant_id=tenant_id,
version=version,
module_path="imsmanifest.xml",
)
5. SCORM から xAPI に移行する場合
SCORM は企業研修の主流の標準ですが、限界もあります 2025 年に重要: 限定的な追跡 (完了/失敗/不完全のみ) バージョン 1.2)、実行が LMS に依存しているため、モバイルでは困難です。 xAPI (ブリキ缶 API) これらすべての制限を克服しますが、 ある 学習レコード ストア (LRS) 別。
SCORM と xAPI: いつ選択するか
| 基準 | SCORM 1.2 / 2004 | xAPI (ブリキ缶) |
|---|---|---|
| 従来の LMS との互換性 | ユニバーサル | 最新の LMS のみ |
| 追跡の粒度 | 完了/失敗/スコア | あらゆる活動 |
| モバイルサポート | 問題がある (iframe) | ネイティブ |
| オフラインサポート | サポートされていません | はい (ローカル LRS を使用) |
| 必要なインフラストラクチャ | LMSのみ | LMS + LRS |
| 相互運用性 | 統合基準 | 新しい標準 |
| 高度な分析 | 限定 | 完了 |
| こんな方におすすめ | 従来の企業研修 | 新しいEdTechシステム |
避けるべきアンチパターン
- すべてのテナントに 1 つの URL: テナントごとに個別の URL がないと、制御されたアクセスや CSS カスタマイズを適用できません。 tenant_id を持つ S3 パスを使用します。
- タイムスタンプによるバージョン管理: semver の代わりにタイムスタンプを使用すると、自動更新ポリシーを管理できなくなります。
- API ラッパーなしの SCORM: ラッパーを使用せずに LMS の SCORM API に直接アクセスすると、コードの移植性がなくなり、テストが困難になります。
- 無効化なしの CDN キャッシュ: コンテンツの更新後、古いバージョンは無効化されない限りキャッシュに残ります。アップロード後は必ず無効化してください。
- SCORM パッケージへの直接変更: SCORM パッケージが公開された後は、決して変更しないでください。常に新しいバージョンを作成してください。
- SCORM 1.2 を無視します。 2025 年には、エンタープライズ LMS の 60% 以上が SCORM 1.2 のみをサポートします。誰もが 2004 を使用するとは思わないでください。
EdTech エンジニアリング シリーズの結論
EdTech エンジニアリングの完全なツアーを完了しました: マルチテナント アーキテクチャから LMS (記事 1) から適応学習アルゴリズム、ビデオ ストリーミングまで AI 監督システム、LLM+RAG 講師からゲーミフィケーション エンジンまで、 学習分析から CRDT とのリアルタイム コラボレーションまで、オフライン ファーストから モバイルから SCORM へのコンテンツ管理。
これらすべてのシステムの共通点は、考える必要があるということです。 大規模に: 後から考えたものではなく、最初からマルチテナントです。 数千ではなく、数百万のユーザーに対するパフォーマンス。プライバシーとコンプライアンス 最終チェックリストとしてではなく、設計上の制約として。
EdTech 市場は急速に成長を続けており、2030 年までに 10 億市場に達します。 の学生は主にプラットフォームを通じて教育コンテンツにアクセスします デジタル。私たちが今日構築するアーキテクチャは、その世代のあり方を定義するでしょう。 学びなさい。それらをうまく構築する価値があります。
EdTech エンジニアリング シリーズ - 完全な概要
- スケーラブルな LMS アーキテクチャ: マルチテナント パターン
- 適応学習アルゴリズム: 理論から本番まで
- 教育向けビデオ ストリーミング: WebRTC vs HLS vs DASH
- AI 監督システム: コンピューター ビジョンによるプライバシー最優先
- LLM を使用した個別の家庭教師: 知識の基礎を築くための RAG
- ゲーミフィケーション エンジン: アーキテクチャとステート マシン
- ラーニング アナリティクス: xAPI と Kafka を使用したデータ パイプライン
- EdTech におけるリアルタイム コラボレーション: CRDT と WebSocket
- モバイルファーストの EdTech: オフラインファーストのアーキテクチャ
- マルチテナント コンテンツ管理: バージョニングと SCORM (この記事)







