Open edX 用户退休(User Retirement)脚本实战:scripts/user_retirement 的部署、配置与运行指南
2026/9/17 20:52:17 网站建设 项目流程

Open edX 用户退休(User Retirement)脚本实战:scripts/user_retirement 的部署、配置与运行指南

【免费下载链接】openedx-platformThe Open edX LMS & Studio, powering education sites around the world!项目地址: https://gitcode.com/GitHub_Trending/ed/openedx-platform

本文基于 openedx-platform 仓库中 scripts/user_retirement/README.rst 展开,讲解 Open edX 用户退休(User Retirement)驱动脚本的完整使用流程:如何以稀疏克隆方式获取脚本、用 uv 搭建运行环境、编写 YAML 驱动配置、执行get_learners_to_retire.pyretire_one_learner.py完成 PII 数据擦除流水线,并配合仓库内 docs 目录下的设计文档与源码,理解其状态机模型、LMS 侧 Django 设置与失败恢复机制。

一、用户退休功能与脚本目录定位

随着 GDPR 等隐私法规的演进,Open edX 平台需要提供"遗忘"用户个人身份信息(PII)的能力。学习者可以在 LMS 账户页面点击Delete My Account确认删除请求,其账户被停用并进入PENDING状态;随后由集中的driver(驱动)脚本编排对 LMS、论坛、ecommerce、credentials 等内部系统以及第三方营销服务的删除/解绑请求。这一设计目标在 docs/index.rst 与 docs/implementation_overview.rst 中有完整阐述。

README.rst 说明,scripts/user_retirement目录中的 Python 脚本是从 edX 早期的tubular仓库迁移而来的,用于驱动用户退休工作流,处理账户的停用与移除。这些脚本设计为可被任意自动化/CI 框架调用。

从源码结构看,该目录的组织方式如下:

  • 顶层驱动脚本:retire_one_learner.py、get_learners_to_retire.py、replace_usernames.py、retirement_archive_and_cleanup.py、retirement_bulk_status_update.py、retirement_partner_report.py
  • 共享工具库utils/:LMS/IDA API 客户端 utils/edx_api.py、Jenkins 属性文件导出 utils/jenkins.py,以及第三方营销 API 封装(utils/thirdparty_apis/ 下的 amplitude、braze、hubspot、salesforce、segment 等)
  • 依赖与配置:pyproject.toml、requirements/base.txt、requirements/testing.txt、uv.lock、pytest.ini
  • 测试:tests/ 目录,包含对每个顶层脚本及第三方 API 客户端的测试用例

二、退休工作流与状态机模型

在理解脚本行为之前,需要先理解 docs/implementation_overview.rst 描述的工作流模型:

  1. 工作流是一条由"积木式 API"组成的可配置流水线,用于"遗忘"用户 PII、阻止用户重新登录、防止用户名/邮箱被复用;
  2. 流水线是线性的、可重跑的(rerunnable),某一阶段失败后可以恢复继续,多个用户可以同时处于不同状态;
  3. LMS 是状态的权威来源UserRetirementStatus模型记录每个用户当前所处的状态,RetirementState模型则存储状态列表本身(而非硬编码),因为 Open edX 社区需要的状态无法预先穷举。

典型的示例状态流转路径为:

PENDING -> RETIRING_ENROLLMENTS -> ENROLLMENTS_COMPLETE -> RETIRING_FORUMS -> FORUMS_COMPLETE -> ... -> COMPLETE

其中任意RETIRING_*状态在 API 调用失败(4xx/5xx)时都会进入终端状态ERROREDPENDING也可进入ABORTED。除工具内部错误外,用户最终必然落在COMPLETE/ERRORED/ABORTED三个终端状态之一。

从 retire_one_learner.py 的源码可以印证这一模型——脚本用常量定义了这些"魔法状态":

# "Magic" states with special meaning, these are required to be in LMS START_STATE = 'PENDING' ERROR_STATE = 'ERRORED' COMPLETE_STATE = 'COMPLETE' ABORTED_STATE = 'ABORTED' END_STATES = (ERROR_STATE, ABORTED_STATE, COMPLETE_STATE)

学习者视角的体验

根据 docs/index.rst,学习者点击Delete My Account按钮并输入密码确认后,其所有浏览器会话会被登出并锁定账户;系统随即发送确认邮件,学习者在此后有限时间内(由驱动脚本的--cool_off_days参数定义)可联系管理员撤销请求。此时账户被停用(deactivated)但尚未退休(retired)UserRetirementStatus表中新增一条记录并置为PENDING

LMS 设置中的FEATURES['ENABLE_ACCOUNT_DELETION'](默认True)控制Delete My Account区块的可见性。另外,通过第三方认证(社交登录)注册的学习者必须先解除 LMS 账户与第三方账户的绑定,否则删除按钮保持禁用状态。

三、获取脚本:稀疏克隆(Sparse Checkout)

README.rst 给出了通过部分克隆(partial clone)只获取脚本目录的方法,避免下载整个 edx-platform 仓库。原文档给出的完整流程如下(repo_url替换为 Open edX 平台仓库的克隆地址):

repo_url=<Open edX 平台仓库地址> branch=master directory=scripts/user_retirement git clone --branch $branch --single-branch --depth=1 --filter=tree:0 $repo_url cd edx-platform git sparse-checkout init --cone git sparse-checkout set $directory

要点说明:

  • --single-branch --depth=1只拉取目标分支的最新一次提交,减少下载量;
  • --filter=tree:0是不带 tree 对象的克隆,配合sparse-checkout实现按目录过滤;
  • git sparse-checkout set $directory之后,工作区中只会出现scripts/user_retirement下的文件。

README 同时指出,也可以选用其他工具或库来完成部分克隆,上述步骤只是演示。

四、安装依赖:uv(推荐)与传统 venv 两种方式

方式一:使用 uv

README.rst 推荐用 uv 安装依赖:

uv sync --project scripts/user_retirement --frozen

--frozen表示直接使用锁定的依赖集合(对应 uv.lock),保证环境可复现。随后所有命令都加上uv run --project scripts/user_retirement前缀,在该虚拟环境中执行;也可以直接激活环境:

source scripts/user_retirement/.venv/bin/activate

方式二:不使用 uv

pyproject.toml 声明了requires-python = ">=3.12",因此不借助 uv 时需要用 Python 3.12 创建虚拟环境,并从兼容性导出文件requirements/base.txt安装:

python3.12 -m venv ../venv source ../venv/bin/activate pip install -r scripts/user_retirement/requirements/base.txt

从源码看,该脚本集的核心依赖(pyproject.toml)包括click(命令行解析)、pyyaml(驱动配置解析)、requests/backoff(API 调用与重试)、edx-rest-api-client(IDP API 客户端)、jenkinsapiunicodecsvsimplejsonsimple-salesforcegoogle-api-python-clientboto3等,与其"编排 LMS + 第三方服务"的定位一致。

五、建立入口点(Entry Points)

README.rst 中说明,先给脚本目录中的入口点脚本加执行权限并 source 它:

chmod +x scripts/user_retirement/entry_points.sh source scripts/user_retirement/entry_points.sh

entry_points.sh 的实际内容是一组 shell alias,把顶层脚本映射为短命令:

alias get_learners_to_retire.py='python scripts/user_retirement/get_learners_to_retire.py' alias replace_usernames.py='python scripts/user_retirement/replace_usernames.py' alias retire_one_learner.py='python scripts/user_retirement/retire_one_learner.py' alias retirement_archive_and_cleanup.py='python scripts/user_retirement/retirement_archive_and_cleanup.py' alias retirement_bulk_status_update.py='python scripts/user_retirement/retirement_bulk_status_update.py' alias retirement_partner_report.py='python scripts/user_retirement/retirement_partner_report.py'

因此,retire_one_learner.py这样的"命令"实际上是走 alias 再调用python 文件路径的执行方式。当然,也可以不依赖 alias,直接用文件路径执行 Python 脚本。

六、驱动配置与两个核心驱动脚本

docs/driver_setup.rst 指出,scripts/user_retirement中包含两个用于驱动退休工作流的脚本,它们共享一个必需的--config_file参数,指向环境的驱动配置文件:

  • get_learners_to_retire.py:生成"已准备好立即退休"的用户列表——即处于PENDING状态且已满足--cool_off_days冷却天数的用户。输出格式为 Jenkins 消费而设计,为每个用户生成一个下游构建。
  • retire_one_learner.py:退休--username参数指定的用户。

6.1 驱动配置文件(YAML)

该配置是一个 YAML 文件,包含 LMS 认证凭据(auth secrets)、API URL 映射和该环境特有的退休流水线阶段。docs/driver_setup.rst 给出的示例:

client_id: <client ID for the retirement service user> client_secret: <client secret for the retirement service user> base_urls: lms: https://courses.example.com/ ecommerce: https://ecommerce.example.com/ credentials: https://credentials.example.com/ retirement_pipeline: - ['RETIRING_EMAIL_LISTS', 'EMAIL_LISTS_COMPLETE', 'LMS', 'retirement_retire_mailings'] - ['RETIRING_ENROLLMENTS', 'ENROLLMENTS_COMPLETE', 'LMS', 'retirement_unenroll'] - ['RETIRING_LMS_MISC', 'LMS_MISC_COMPLETE', 'LMS', 'retirement_lms_retire_misc'] - ['RETIRING_LMS', 'LMS_COMPLETE', 'LMS', 'retirement_lms_retire']

各字段含义:

  • client_id/client_secret:OAuth 凭据,直接复制自create_dot_application管理命令的输出(见下文"退休服务用户"一节);
  • base_urls:IDA 名称到基础 URL 的映射,脚本据此拼接 API 地址。LMS 是必需的;若流水线中还有对其它服务的 API 调用(ecommerce、credentials 等),这些服务也必须出现在base_urls中;
  • retirement_pipeline:定义每个环境的执行步骤、状态名与顺序。每一项是一个四元素列表:
    1. 起始状态名(start state)
    2. 结束状态名(end state)
    3. 要调用的服务 key(如LMSLICENSE_MANAGER
    4. 在 utils/edx_api.py 中调用的方法名

例如['RETIRING_ENROLLMENTS', 'ENROLLMENTS_COMPLETE', 'LMS', 'retirement_unenroll']的含义是:先把用户状态置为RETIRING_ENROLLMENTS,调用已实例化的LmsApi.retirement_unenroll方法,成功后把状态置为ENROLLMENTS_COMPLETE

从源码看,这一解释与 retire_one_learner.py 的主循环完全吻合:

for start_state, end_state, service, method in config['retirement_pipeline']: # Skip anything that has already been done if config['all_states'].index(start_state) < learner_state_index: LOG('State {} completed in previous run, skipping'.format(start_state)) continue ... config['LMS'].update_learner_retirement_state(username, start_state, 'Starting: {}'.format(start_state)) # This does the actual API call response = getattr(config[service], method)(learner) ... config['LMS'].update_learner_retirement_state(username, end_state, 'Ending: {} with response:\n{}'.format(end_state, response))

这里能看到"可重跑"设计的关键实现:每次执行都先向 LMS 查询用户当前状态并映射到流水线中的索引,凡是索引早于当前状态的阶段直接跳过;每个阶段开始/结束时都会调用update_learner_retirement_state回写 LMS,把响应内容写入状态记录。若中途抛异常,脚本会把用户置为ERRORED并带上异常信息(见下文"从 ERRORED 恢复")。

retire_one_learner.py 顶部还定义了各失败场景的返回码,便于 CI 框架区分失败原因:

ERR_SETUP_FAILED = -1 ERR_USER_AT_END_STATE = -2 ERR_USER_IN_WORKING_STATE = -3 ERR_WHILE_RETIRING = -4 ERR_BAD_LEARNER = -5 ERR_UNKNOWN_STATE = -6 ERR_BAD_CONFIG = -7

其中ERR_USER_AT_END_STATE(用户已在终端状态)与ERR_USER_IN_WORKING_STATE(用户正处于RETIRING_*工作态,可能有另一进程在处理)都来自 retire_one_learner.py 的状态校验逻辑——它要求学习者不在END_STATES中、也不在config['working_states'](即流水线各项的第一个状态)中,否则会直接退出,防止并发重复执行同一用户。

6.2 获取待退休学习者列表

docs/driver_setup.rst 给出的示例:

mkdir learners_to_retire get_learners_to_retire.py \ --config_file=path/to/config.yml \ --output_dir=learners_to_retire \ --cool_off_days=5

从 get_learners_to_retire.py 源码可以补全参数与默认值的完整说明(脚本用click解析参数,且支持RETIREMENT_前缀的环境变量传参):

参数默认值说明
--config_file无(必填)YAML 配置文件路径
--cool_off_days7学习者在退休队列(PENDING)中需停留的天数,达到后才会被实际退休
--output_dir./jenkins_props输出 Jenkins 属性文件的目录
--user_count_error_threshold300安全阀:若返回的用户数超过该值,直接报错退出而不退休,防止攻击者向退休队列批量注入用户
--max_user_batch_size200单次最多获取 X 个用户;若低于user_count_error_threshold则只截断不报错

脚本的执行逻辑(get_learners_to_retire.py):读取 YAML 配置后,构造states_to_request = ['PENDING'] + end_states(即PENDING加上流水线各阶段的结束状态),调用LmsApi.learners_to_retire(...)拉取列表,按max_user_batch_size截断、按user_count_error_threshold检查,最后通过 utils/jenkins.py 的export_learner_job_properties把结果写成每用户一个的 INI 属性文件。

6.3 对单个学习者执行退休

README.rst 与 docs/driver_setup.rst 都给出了对单个用户执行退休的命令。运行完get_learners_to_retire.py后,输出目录中会有若干 INI 文件,每个文件包含一行USERNAME=<username-of-learner>;遍历这些文件,对每个用户执行:

retire_one_learner.py \ --config_file=path/to/config.yml \ --username=<username-of-learner-to-retire>

也可以直接用文件路径执行 Python 脚本:

python scripts/user_retirement/retire_one_learner.py \ --config_file=src/config.yml \ --username=user1

记得把src/config.yml替换为你的实际配置文件路径、user1替换为实际用户名。retire_one_learner.py 文件头部的 docstring 中还给出了本地环境(devstack 风格的端口)的配置示例,其中流水线示例使用了LICENSE_MANAGER服务:

client_id: <client id from LMS DOT> client_secret: <client secret from LMS DOT> base_urls: lms: http://localhost:18000/ ecommerce: http://localhost:18130/ credentials: http://localhost:18150/ retirement_pipeline: - ['RETIRING_LICENSE_MANAGER', 'LICENSE_MANAGER_COMPLETE', 'LICENSE_MANAGER', 'retire_learner'] - ['RETIRING_FORUMS', 'FORUMS_COMPLETE', 'LMS', 'retirement_retire_forum'] - ['RETIRING_EMAIL_LISTS', 'EMAIL_LISTS_COMPLETE', 'LMS', 'retirement_retire_mailings'] - ['RETIRING_ENROLLMENTS', 'ENROLLMENTS_COMPLETE', 'LMS', 'retirement_unenroll'] - ['RETIRING_LMS', 'LMS_COMPLETE', 'LMS', 'retirement_lms_retire']

另外,从 entry_points.sh 与目录结构看,该目录还提供了一批配套运维脚本,用于流水线的收尾与报告:replace_usernames.py(退休后的用户名处理)、retirement_archive_and_cleanup.py(归档与清理退休队列数据)、retirement_bulk_status_update.py(批量更新状态)、retirement_partner_report.py(面向合作方/第三方的退休报告)。这些同样通过 alias 暴露为短命令,测试覆盖见 tests/test_retirement_archive_and_cleanup.py、tests/test_retirement_bulk_status_update.py 等文件。

七、LMS 侧设置:Django 设置、状态表与服务用户

docs/service_setup.rst 描述了 LMS 侧需要完成的设置,这是驱动脚本能够工作的另一半前提。

7.1 控制用户退休行为的 Django 设置

设置名默认值说明
RETIRED_USERNAME_PREFIX'retired__user_'哈希用户名所用的前缀,供RETIRED_USERNAME_FMT使用
RETIRED_EMAIL_PREFIX'retired__user_'哈希邮箱所用的前缀,供RETIRED_EMAIL_FMT使用
RETIRED_EMAIL_DOMAIN'retired.invalid'哈希邮箱的域名部分,供RETIRED_EMAIL_FMT使用
RETIRED_USERNAME_FMTlambda settings: settings.RETIRED_USERNAME_PREFIX + '{}'退休后用户名转换成的格式,{}被用户名的哈希值替换
RETIRED_EMAIL_FMTlambda settings: settings.RETIRED_EMAIL_PREFIX + '{}@' + settings.RETIRED_EMAIL_DOMAIN退休后邮箱转换成的格式,{}被邮箱哈希值替换
RETIRED_USER_SALTSNone用户名/邮箱哈希使用的盐值列表。只有列表最后一项用于所有新的退休;历史盐值保留以保证所有历史哈希值仍可校验。默认值必须覆盖!
RETIREMENT_SERVICE_WORKER_USERNAME'RETIREMENT_SERVICE_USER'退休服务 worker 的用户名
RETIREMENT_STATESlms/envs/common.py中的RETIREMENT_STATES设置定义退休工作流状态名与顺序的列表
FEATURES['ENABLE_ACCOUNT_DELETION']True是否在账户设置页面显示 "Delete My Account" 区块

文档特别指出,部分设置的值是 lambda 函数而非普通字符串字面量,这是 Open edX 特有的"派生设置(derived settings)"模式。

7.2 退休状态表与 populate_retirement_states

每个用户的退休状态存储在 LMS 数据库中,状态列表本身也单独存储在数据库中(RetirementState模型)。由于状态列表会随时间、随不同安装而变化,填充状态列表是管理员的责任。文档对状态列表提出了硬性约束:

  • 至少要有开头的PENDING状态,以及结尾的COMPLETEDERROREDABORTED状态;
  • 每一个RETIRING_foo状态都必须有对应的foo_COMPLETE状态。

如需自定义状态,通常在lms.yml中覆盖RETIREMENT_STATES,然后用管理命令把状态表与设置同步:

$ ./manage.py lms --settings=<your-settings> populate_retirement_states All states removed and new states added. Differences: Added: set([u'RETIRING_ENROLLMENTS', u'RETIRING_LMS', u'LMS_MISC_COMPLETE', u'RETIRING_LMS_MISC', u'ENROLLMENTS_COMPLETE', u'LMS_COMPLETE']) Removed: set([]) Remaining: set([u'ERRORED', u'PENDING', u'ABORTED', u'COMPLETE']) States updated successfully. Current states: PENDING (step 1) RETIRING_ENROLLMENTS (step 11) ENROLLMENTS_COMPLETE (step 21) ... COMPLETE (step 91)

该命令是幂等的:始终让状态表与设置中的RETIREMENT_STATES列表保持一致;已存在的状态列在Remaining下不会重复添加。

7.3 创建退休服务用户

驱动脚本以"退休服务用户"的身份、通过 OAuth 客户端凭据与 LMS/IDA 认证,因此必须创建该用户并生成 DOT 应用与客户端凭据:

app_name=retirement user_name=retirement_service_worker ./manage.py lms --settings=<your-settings> manage_user $user_name $user_name@example.com --staff --superuser ./manage.py lms --settings=<your-settings> create_dot_application $app_name $user_name

注意:客户端凭据(client ID 与 client secret)只会打印到终端一次,需要及时复制保存,之后填入驱动脚本的 YAML 配置。

退休服务用户还需要具备执行退休任务的权限,通过在 Django 设置中指定RETIREMENT_SERVICE_WORKER_USERNAME完成:

RETIREMENT_SERVICE_WORKER_USERNAME = 'retirement_service_worker'

7.4 Django 管理后台入口

Django Admin 的USER_API分组下有三个与用户退休相关的模型:

名称URI说明
Retirement States/admin/user_api/retirementstate/状态表,由RETIREMENT_STATES定义、populate_retirement_states填充
User Retirement Requests/admin/user_api/userretirementrequest/记录所有曾请求删除账户的用户 ID,主要用于内部审计记账
User Retirement Statuses/admin/user_api/userretirementstatus/管理每个学习者退休状态的核心模型,特殊情况下可在此手动改状态

八、处理特殊情况:失败恢复、批量重跑与取消退休

docs/special_cases.rst 覆盖了运维中最重要的三类场景。

8.1 从 ERRORED 状态恢复

当某个退休 API 返回失败(4xx 或 5xx),驱动脚本会立即把用户状态置为ERRORED。排查方法:查看user_api_userretirementstatus表中该用户行的responses字段(脚本每次状态切换都会把 API 响应写入该字段,见 retire_one_learner.py 中的update_learner_retirement_state调用)。问题修复后,手动把current_state重置为"应重试状态的前一个状态"(例如在 forums 退休阶段出错,则从ERRORED改回ENROLLMENTS_COMPLETE)。改完之后,驱动脚本下次执行时会自动从断点继续——这正是 retire_one_learner.py 中"跳过已完成阶段"逻辑带来的可重跑能力。

8.2 重跑部分或全部状态

如果想从最初开始重跑所有退休,把所有current_state == COMPLETE的记录改回PENDING即可。典型场景:全体退休跑完之后(但清理退休队列之前)新加了一个流水线阶段,或某个阶段/API 开发时实际未生效却返回了成功。退休 API 被设计为幂等的,对已经跑完的阶段重跑应该是无副作用的(no-op)。

8.3 取消退休请求

刚提交删除请求、仍处于PENDING状态的用户,可以通过联系管理员撤销请求。平台提供管理命令,给定用户邮箱即可恢复其登录能力并使其退出所有退休队列:

$ ./manage.py lms --settings=<your-settings> cancel_user_retirement_request <email-of-user-to-cancel-retirement>

限制:该命令只对状态尚未越过PENDING的用户有效;且用户恢复访问前需要重置密码。

九、扩展退休流水线

docs/extending_retirement.rst 说明,当前退休代码只支持有限的一组服务(LMS、Ecommerce、Course Discovery 以及部分历史第三方提供方),要接入新系统(例如学校的邮件服务商),有两种主要方式:

  1. LMS 内监听 Django Signal:用户退休时 LMS 会发射名为USER_RETIRE_LMS_MISC的信号(定义于openedx.core.djangoapps.user_api.accounts.signals),可以编写 edx-platform 插件监听该信号,在 LMS 内直接执行清理动作;
  2. 带外(out of band)进程:利用退休 API 查询"近期退休"的用户,在用户退休与退休数据被完全清除之间的时间窗口内,把用户传播到外部系统并删除。具体可参照 utils/edx_api.py 的LmsApi,例如用get_learners_by_date_and_status方法查找COMPLETE状态且最近 24 小时内更新的用户。该方式没有第一种的状态保证,但能执行更灵活的动作。

十、运行测试用例

README.rst 最后给出了测试的两种运行方式。

使用 uv:

uv run --project scripts/user_retirement --group test pytest scripts/user_retirement

--group test对应 pyproject.toml 中的test依赖组(pytestrequests_mockresponsesmotomockddt等)。

不使用 uv:先安装测试依赖再直接运行 pytest:

pip install -r scripts/user_retirement/requirements/testing.txt pytest scripts/user_retirement

测试用例分布在 tests/ 目录,按脚本与 API 客户端组织,例如 tests/test_get_learners_to_retire.py、tests/test_retire_one_learner.py、tests/utils/test_edx_api.py,以及 tests/utils/thirdparty_apis/ 下对 amplitude、braze、hubspot、salesforce、segment 客户端的测试。

十一、小结:一条可复现的退休流水线

把 README 与 docs 串起来,一个完整的部署闭环是:

  1. 稀疏克隆仓库,只取scripts/user_retirement
  2. uv sync --project scripts/user_retirement --frozen建好环境;
  3. LMS 侧配置RETIREMENT_STATESRETIRED_USER_SALTS等 Django 设置,执行populate_retirement_states同步状态表,创建退休服务用户并记录 DOT 客户端凭据;
  4. 编写含client_id/client_secret/base_urls/retirement_pipeline四段 YAML 的驱动配置;
  5. source scripts/user_retirement/entry_points.sh后,按"拉名单 → 逐人退休"的节奏运行get_learners_to_retire.py(注意cool_off_daysuser_count_error_threshold安全阀)与retire_one_learner.py
  6. 失败时借助responses字段定位,在 Django Admin 中把ERRORED重置回断点前的状态,重新执行即可自动续跑;
  7. 需要接第三方系统时,优先监听USER_RETIRE_LMS_MISC信号,或用LmsApi.get_learners_by_date_and_status做带外同步。

整套工具的可重跑性、幂等 API 设计、LMS 作为状态权威源这三点,是它与一次性数据擦除脚本的本质区别,也是运维时最重要的心智模型。

【免费下载链接】openedx-platformThe Open edX LMS & Studio, powering education sites around the world!项目地址: https://gitcode.com/GitHub_Trending/ed/openedx-platform

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询