Open edX external_user_ids 应用详解:用户外部 ID 的建模、生成与集成
【免费下载链接】openedx-platformThe Open edX LMS & Studio, powering education sites around the world!项目地址: https://gitcode.com/GitHub_Trending/ed/openedx-platform
导读
本文以 Open edX 仓库中openedx/core/djangoapps/external_user_ids应用及其官方文档(README.rst)为主体,深入讲解该应用的核心职责——在不暴露平台内部数据库用户 ID的前提下,为每个用户按“类型”分配可对外共享的 UUID 外部标识,并支持由外部 ID 反向还原内部用户。读完本文,你将掌握ExternalId/ExternalIdType两个核心模型的设计约束、三类关键类方法(查询、单用户创建、批量创建)的调用语义,以及如何通过 Django Admin 上传 CSV 批量生成外部 ID,并能依据仓库源码与测试用例理解其底层实现。
一、应用职责与设计动机
根据官方 README.rst,external_user_ids应用的核心职责(Responsibilities)是:
将 ID 与用户关联。用户关联的内部数据库 ID 可能不适合发送到 Open edX 之外,因此本应用为用户保存外部 ID。
也就是说,该应用是一个双向映射层:
- 将内部用户 ID → 外部用户 ID:为内部
User生成一个特定类型(type)的外部 ID; - 将外部用户 ID → 内部用户 ID:当外部系统把该 ID 回传给 Open edX 时,可以据此重新定位到具体用户。
这种设计在第三方集成场景中非常典型:外部系统(如数据管道、学习记录存储 LRS、LTI 工具方)往往要求随数据附带一个稳定的用户唯一标识,但平台内部的数据库自增主键属于内部数据模型,不应直接外泄。决策文档 0001-externalid.rst 中明确记录了这一动机:
第三方有时会要求获取学习者数据中的唯一用户 ID。我们希望避免向他们发送内部 ID,以保护内部数据模型。
与匿名用户 ID(AnonymousUserId)的区别
仓库中存在另一套 ID 机制——common/djangoapps/student/models/user.py中的 AnonymousUserId 模型。它与外部 ID 的关键差异在于:
| 维度 | AnonymousUserId | ExternalId |
|---|---|---|
| 生成方式 | 基于 MD5 算法生成 32 字节 hex 字符串 | UUID(uuid.uuid4) |
| 作用域 | 与(user, course_id)组合绑定 | 与(user, external_id_type)组合绑定 |
| 语义 | 匿名标识,用于不暴露身份的场景(如个性化调查链接) | 非匿名,随可识别信息一起共享给外部实体 |
决策文档特别强调:匿名 ID 表的用例是提供匿名用户标识,而外部 ID 会与可识别信息一起被共享,因此外部 ID 不属于匿名 ID。这是选择独立建表、而非复用匿名 ID 表的根本原因。
二、数据模型:ExternalIdType 与 ExternalId
该应用的全部模型定义在 models.py 中,仅包含两个实体,并都继承了model_utils的TimeStampedModel(自动维护created/modified时间戳)与simple_history的HistoricalRecords(自动保存历史版本)。
1. ExternalIdType:外部 ID 的类型(用途)表
class ExternalIdType(TimeStampedModel): CALIPER = 'caliper' XAPI = 'xapi' LTI = 'lti' name = models.CharField(max_length=32, blank=False, unique=True, db_index=True) description = models.TextField() history = HistoricalRecords()ExternalIdType定义了外部 ID 的“类型(type)”,即该 ID 的用途或预期消费方。一个用户可以有发给 A 公司的 ID,也可以有发给 B 公司的 ID,二者通过name区分。
name:类型名称,max_length=32,不允许为空、全局唯一、加数据库索引;description:自由文本描述,决策文档要求所有类型都必须有清晰描述,以便日后判断该类型为何创建(例如“若该类型是为发送给 A 公司而创建,描述可以帮助判断该类型的 ID 是否也可以发送给 B 公司”);- 模块常量:
CALIPER = 'caliper'、XAPI = 'xapi'、LTI = 'lti'是平台内置的三类预置类型(见下文“迁移演进”一节)。
2. ExternalId:用户的外部 ID 记录
class ExternalId(TimeStampedModel): external_user_id = models.UUIDField(default=uuid_tools.uuid4, editable=False, unique=True) external_id_type = models.ForeignKey(ExternalIdType, db_index=True, on_delete=models.CASCADE) user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) history = HistoricalRecords() class Meta: unique_together = (('user', 'external_id_type'),)external_user_id:对外暴露的 UUID,默认由uuid.uuid4生成,不可编辑、全局唯一,即对外 ID 永远是 UUID 格式;external_id_type:指向ExternalIdType的外键,级联删除,加索引;user:指向 DjangoUser的外键,级联删除,加索引;- 联合唯一约束
unique_together = (('user', 'external_id_type'),):这落实了决策文档中的关键规则——同一用户、同一类型下只能有且仅有一个外部 ID("Users will only have exactly 1 external id per ExternalIDType")。这个约束既保证了映射的确定性(反查不会出现歧义),也防止批量生成时产生重复记录。
初始迁移 0001_initial.py 中可以看到两张业务表各自配套的历史表(HistoricalExternalId、HistoricalExternalIdType),它们由simple_history自动维护增、改、删三种操作记录。
三、核心 API:查询、单用户创建与批量创建
models.py 在ExternalId上提供了三个可直接被业务代码调用的类方法,这也是该应用向平台其他模块开放的主要编程接口。
1. user_has_external_id(user, type_name) —— 判断是否存在
@classmethod def user_has_external_id(cls, user, type_name): if not cls.objects.filter( user=user, external_id_type__name=type_name ).exists(): return False return True按user与type_name(类型名称字符串)查询,返回布尔值。实现上通过external_id_type__name跨表过滤,不需要预先加载ExternalIdType对象。
2. add_new_user_id(user, type_name) —— 单用户创建/获取
@classmethod def add_new_user_id(cls, user, type_name): try: type_obj = ExternalIdType.objects.get(name=type_name) except ExternalIdType.DoesNotExist: LOGGER.info('External ID Creation failed ...') return None, False external_id, created = cls.objects.get_or_create( user=user, external_id_type=type_obj ) ... return external_id, created核心语义:
- 若
type_name对应的类型不存在,记录LOGGER.info日志并返回(None, False)——不会自动创建类型,调用方需自行保证类型已就绪; - 类型存在时,使用
get_or_create以(user, external_id_type)查重(与联合唯一约束一致),已存在则直接复用; - 返回值是
(external_id, created)二元组,created为True表示本次新建,False表示已存在。
3. batch_get_or_create_user_ids(users, type_name) —— 批量创建
@classmethod def batch_get_or_create_user_ids(cls, users, type_name): ... user_ids = {user.id for user in users} externalid_count = models.Count('externalid', filter=models.Q(externalid__external_id_type=type_obj)) users_wo_externalid = User.objects.annotate(externalid_count=externalid_count).filter( externalid_count=0, id__in=user_ids, ) existing_externalids = cls.objects.filter(user__in=users, external_id_type=type_obj) result = {eid.user_id: eid for eid in existing_externalids} if len(users_wo_externalid) > 0: new_externalids = cls.objects.bulk_create([ cls(user=user, external_id_type=type_obj) for user in users_wo_externalid ]) result.update({eid.user_id: eid for eid in new_externalids}) return result批量场景下它做了三件事:
- 类型校验:类型不存在时记录日志并返回
None(调用方需判空); - 只补缺:用
annotate + Count + Q过滤出“该类型下尚无外部 ID”的用户子集,仅对这些用户执行bulk_create,一次性批量插入,避免 N+1 查询; - 合并返回:把已存在的记录与新建记录合并成一个
{user_id: ExternalId}字典返回,方便调用方按用户 ID 直接取到外部 ID。
测试 test_batch_generate_id.py 从侧面印证了该方法的性能特征:它断言整个批量流程只产生5~6 条 SQL 查询(类型查询、缺省用户查询、已有记录查询、事务与插入等),并对“部分用户已有 ID 时不会重复创建”“传入非法类型时返回 None”两个边界都做了覆盖。测试工厂定义在 factories.py,分别提供ExternalIDTypeFactory与ExternalIdFactory供测试快速造数。
四、Django Admin 批量生成外部 ID(CSV 上传)
除了编程接口,该应用还在 Django Admin 中提供了面向运营人员的 CSV 批量生成工具,实现在 admin.py。
1. 自定义管理入口
ExternalIdAdmin注册在ExternalId上,并重写了get_urls(),追加一个自定义 URL:
admin:external_user_ids_externalid_bulk_generate_external_ids对应的视图函数为generate_ids_form,其变更列表页模板被替换为自定义模板admin/external_user_ids/generate_external_user_ids.html(代码中同时保留了表单模板路径admin/external_user_ids/generate_external_ids_form.html)。
2. 表单与 CSV 格式要求
管理页面使用CsvImportForm,包含两个字段:
csv_file:CSV 文件;id_type:ExternalIdType的下拉选择。
后端对 CSV 的校验规则(generate_ids_form中实现):
- 文件必须以 UTF-8 解码;
- 第一行必须是表头,且只能有一列名为
ID(len(headers) != 1 or 'ID' not in headers则报错:"To many columns or incorrectly named ID column"); - 其余每行必须是整数形式的用户 ID,任一非整数都会整体报错:"All ids must be integers";
- 未上传文件或未选择类型时,直接报错并重定向回列表页。
一个合法的 CSV 示例:
ID 1001 1002 10033. 处理流程与结果反馈
process_generate_ids_request按用户 ID 列表执行处理:
- 用
User.objects.filter(id__in=user_id_list)批量查出存在的用户; - 对每个用户执行
ExternalId.objects.get_or_create(user=user, external_id_type=id_type)——已存在的外部 ID 不会重复创建; - 汇总统计四类结果:尝试创建的 ID 列表、未找到的用户、新建成功的外部 ID、已存在的外部 ID,通过 Django 的
messages.SUCCESS反馈给管理员,并记录logger.info日志。
测试 test_admin_generate_id.py 覆盖了两种关键场景:10 个用户全部无外部 ID 时一次生成 10 条;10 个用户已全部有外部 ID 时再次处理,记录数保持不变(幂等)。
五、内置类型的数据迁移演进
ExternalIdType的预置类型并非写死在代码里,而是通过数据迁移(RunPython)逐步写入数据库的,这体现了“类型即数据”的设计思路。迁移文件展示了完整的演进历史:
| 迁移文件 | 说明 |
|---|---|
| 0001_initial.py | 创建两张业务表及历史表 |
| 0004_add_lti_type.py | 新增lti类型,描述为 "LTI Xblock launches" |
| 0005_add_caliper_and_xapi_lti_types.py | 新增caliper(Caliper Specification event transformer)与xapi(xAPI Specification event transformer)类型 |
| 0007 / 0008 | 移除历史上的mbcoaching相关外部 ID 及其类型 |
| 0009 | MariaDB 环境下的 UUID 字段兼容转换 |
以0004_add_lti_type.py为例,迁移通过apps.get_model('external_user_ids', 'ExternalIdType')拿到历史模型并执行update_or_create,同时提供可逆的delete_lti_type反向操作;0005则一次性写入caliper与xapi两个类型。可以看到,这些内置类型都是围绕外部事件/LTI 集成场景设计的——当事件按 Caliper 或 xAPI 规范经转换器发送到外部 LRS 时,携带的正是对应类型的外部用户 ID。
六、在其他模块中的集成方式
该应用定义在openedx.core.djangoapps.external_user_ids,作为 Open edX 核心应用(详见 apps.py 中的ExternalUserIDConfig),可在其他模块中直接导入模型使用。仓库中一个可参考的真实引用场景是 test_retirement_views.py:在用户账户相关测试中,通过
ExternalIdType.objects.get_or_create(name=ExternalIdType.CALIPER)预置caliper类型来配合用户数据生命周期流程的验证。这从侧面说明:外部 ID 与用户数据的生命周期管理(如用户注销/退休时清理关联 ID)是强相关的,集成方应留意ExternalId上on_delete=models.CASCADE的级联删除行为。
七、实战要点小结
- 对外永远只暴露 UUID:
ExternalId.external_user_id是唯一对外字段,editable=False保证其不可被随意改写; - 映射方向是双向的:既能由内部用户拿到外部 ID(
add_new_user_id/batch_get_or_create_user_ids),也能由外部 ID 反查用户(通过external_user_id唯一索引直接get); - 一个用户、一个类型、一个 ID:
unique_together联合唯一约束 +get_or_create/bulk_create补缺策略,确保幂等且无重复; - 类型必须先存在:三个 API 在类型不存在时都只返回失败(
None)或记录日志,不会隐式建类型,业务侧需通过数据迁移或get_or_create预置类型; - 运营批量导入走 Admin:CSV 需满足“单列
ID表头 + 整数行”格式,处理结果会以 Django message 形式汇总反馈; - 关注数据生命周期:
ExternalId对User为级联删除,用户账户被删除时其外部 ID 记录会自动清理。
该应用的完整实现、决策依据与测试覆盖,可进一步查阅仓库中的 models.py、admin.py、决策文档 以及 tests 目录。
【免费下载链接】openedx-platformThe Open edX LMS & Studio, powering education sites around the world!项目地址: https://gitcode.com/GitHub_Trending/ed/openedx-platform
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考