다중 테넌트 콘텐츠 관리: 버전 관리 및 SCORM
대규모 교육 회사가 500개의 기업 고객에게 서비스를 제공하고 있으며 각 고객에게는 고유한 서비스가 제공됩니다. 직원, 시각적 정체성 및 규정 준수 요구 사항. eLearning 콘텐츠 제공업체는 정책 양식을 업데이트해야 합니다. 보안에 대해(매년 변경됨) 변경 사항을 500명의 고객 모두에게 전파합니다. 즉시, 그들 중 누구도 아무것도 할 필요 없이. 고객이 자신만의 로고와 예시를 사용하여 양식을 사용자 정의하려면 다음을 수행할 수 있어야 합니다. 자동 업데이트를 중단하지 않고 수행하십시오.
이것이 문제이다 다중 테넌트 콘텐츠 관리 교육 기술용: 공유 및 개인화된 콘텐츠 관리, 올바른 버전 지정, 배포 효율적으로 규정 준수를 보장합니다. 스크롤 (공유 가능한 콘텐츠 객체 참조 모델)은 기업 eLearning 패키지의 가장 널리 사용되는 표준입니다.
이 기사에서는 데이터 구조에서 콘텐츠까지 완전한 시스템을 구축합니다. 테넌트별 재정의, 의미론적 패키지 버전 관리 기능을 갖춘 다중 테넌트, 타사 LMS와의 통신을 위해 SCORM 호환 엔드포인트로, 효율적인 글로벌 유통을 위한 CDN 전략까지
이 기사에서 배울 내용
- 테넌트별 사용자 정의가 가능한 공유 콘텐츠를 위한 다중 테넌트 데이터 모델
- eLearning 콘텐츠 패키지에 대한 의미론적 버전 관리(semver)
- SCORM 1.2 및 SCORM 2004: 차이점, 구조 및 통신 API
- 다중 테넌트 SCORM 패키지에 대한 중앙 집중식 호스팅과 분산 호스팅
- 콘텐츠 패키징: ZIP, imsmanifest.xml 매니페스트 및 파일 구조
- 고급 추적을 위한 SCORM API JavaScript 래퍼
- 저지연 글로벌 배포를 위한 CDN 전략
- SCORM에서 xAPI로 마이그레이션: 시기와 이유
1. 콘텐츠에 대한 다중 테넌트 데이터 모델
다중 테넌트 콘텐츠 관리의 과제는 두 가지 상반된 요구 사항의 균형을 맞추는 것입니다. 공유 (업데이트된 콘텐츠가 모든 테넌트에게 전파됨) 전자 맞춤화 (각 테넌트는 특정 항목을 재정의할 수 있습니다). 우리는 모델 a를 사용합니다. 세 가지 수준: 글로벌 콘텐츠(마스터), 범주 재정의(유사한 특성을 가진 테넌트 그룹) 및 재정의 테넌트별.
# 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 |
| 상호 운용성 | 통합표준 | 새로운 표준 |
| 고급 분석 | 제한된 | 완벽한 |
| 권장 대상 | 레거시 기업 교육 | 새로운 교육 기술 시스템 |
피해야 할 안티패턴
- 모든 테넌트에 대한 하나의 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조)부터 적응형 학습 알고리즘, 비디오 스트리밍까지 LLM+RAG 교사부터 게임화 엔진까지, AI 감독 시스템까지, 분석 학습부터 CRDT를 통한 실시간 협업까지, 오프라인 우선부터 모바일에서 SCORM 콘텐츠 관리로.
이 모든 시스템의 공통 분모는 사고의 필요성입니다. 대규모로: 나중에 생각하는 것이 아니라 처음부터 다중 테넌트입니다. 수천 명이 아닌 수백만 명의 사용자를 위한 성능; 개인 정보 보호 및 규정 준수 최종 체크리스트가 아닌 설계 제약 조건으로 사용됩니다.
EdTech 시장은 지속적으로 빠르게 성장하여 2030년까지 10억 달러 규모로 성장할 것입니다. 의 학생들이 주로 플랫폼을 통해 교육 콘텐츠에 접근합니다. 디지털. 오늘날 우리가 구축하는 아키텍처는 해당 세대의 방식을 정의합니다. 배우다. 그것들을 잘 구축하는 것은 가치가 있습니다.
EdTech 엔지니어링 시리즈 - 전체 요약
- 확장 가능한 LMS 아키텍처: 다중 테넌트 패턴
- 적응형 학습 알고리즘: 이론에서 생산까지
- 교육용 비디오 스트리밍: WebRTC, HLS, DASH
- AI 감독 시스템: 컴퓨터 비전을 통한 개인정보 보호 우선
- LLM을 통한 맞춤형 교사: 지식 기반 구축을 위한 RAG
- 게임화 엔진: 아키텍처 및 상태 머신
- 학습 분석: xAPI 및 Kafka를 사용한 데이터 파이프라인
- EdTech의 실시간 협업: CRDT 및 WebSocket
- 모바일 우선 교육 기술: 오프라인 우선 아키텍처
- 다중 테넌트 콘텐츠 관리: 버전 관리 및 SCORM(이 문서)







