课程平台多端分发架构:一套课程数据同时供给官网、小程序与 AI 引擎的输出管道设计
一、先说结论:问题不在渠道,在数据源
多数课程平台的现状是这样的:官网一套课程详情模板、小程序一套数据接口、运营偶尔还往第三方专栏平台搬运一份。同一门课的标题、简介、大纲在三条渠道里各有一份"版本",改一次价格要改三处,上线一门新课要发三次内容。做 GEO 改造时又会被要求"给 AI 引擎单独出一套可读页面",如果不先收敛数据源,就变成第四个版本。
我们给一个中型课程平台做改造时,把目标定成一句话:课程数据只有一份,所有渠道都是这份数据的投影。官网是投影、小程序是投影、给 AI 爬虫的预渲染页面与 JSON-LD 也是投影。这篇文章讲这条输出管道怎么设计、哪些环节容易做歪、以及上线后各渠道的一致性怎么验证。
二、整体架构:一条主数据管道,四个出口
2.1 分层结构
┌─────────────────────────────────────────────────┐
│ 课程主数据库 │
│ course / chapter / lesson / price / instructor │
└──────────────────────┬──────────────────────────┘
│ 变更事件(发布/更新/下架)
┌──────────────┼──────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌────────────┐ ┌──────────────────┐
│ 官网 API │ │ 小程序 API │ │ 静态化渲染服务 │
│ (动态接口) │ │ (动态接口) │ │ (预渲染 HTML+JSON-LD)│
└───────────────┘ └────────────┘ └────────┬─────────┘
▼
┌─────────────────────┐
│ 静态站 CDN / AI 爬虫 │
└─────────────────────┘
关键设计点有三个。第一,动态渠道(官网、小程序)直接读主库,保证运营改价、改库存的实时性;第二,静态渠道(给搜索引擎与 AI 引擎的页面)由变更事件触发重新渲染,而不是定时全量——定时全量在课程数上千后既慢又浪费;第三,所有渠道的字段口径统一在主库层定义,比如"课程简介"就是 summary 字段,各端不许再各自截断、各自改写。
字段口径的统一必须落成文档和约束,而不是口头约定。我们在主库层给核心字段定了一张口径表,各端开发以此为唯一依据:
| 主库字段 | 口径定义 | 各端使用约束 |
|---|---|---|
| title | 课程标题,≤40 字,不含营销后缀 | 全端原样输出,禁止拼接"限时特惠"等后缀 |
| summary | 纯文本简介,120~200 字,不含 HTML | 官网/小程序可截断展示,静态页输出全文 |
| outline | 章节数组,每章 title ≤30 字 | 静态页必须全部输出,小程序按需折叠 |
| price | 整数,单位分 | 各端统一除以 100 展示,禁止各自维护划线价 |
| instructor_ids | 讲师外键数组 | 讲师实体同样单一来源,禁止端上缓存旧头衔 |
这张表的价值在纠纷出现时才显出来:此前"简介被截断导致关键卖点丢失""价格显示差一分钱"这类扯皮,源头都是口径未定义。口径表加上代码评审时的对照检查,新接口再没出过口径事故。
2.2 变更事件的捕获
课程主库用 MySQL,变更事件用两个通道互补:后台保存时业务代码显式发事件(覆盖 99% 的正常路径),另加一个基于 updated_at 的对账任务(每小时跑一次,兜底漏发与直改库的场景):
# 对账任务:找出近 2 小时有变更但未生成静态页的课程
import pymysql
from datetime import datetime, timedelta
def reconcile(conn, static_cache):
cur = conn.cursor()
since = datetime.utcnow() - timedelta(hours=2)
cur.execute(
"select id, updated_at from courses "
"where status='published' and updated_at > %s",
(since,),
)
stale = []
for course_id, updated_at in cur.fetchall():
cached = static_cache.get(course_id)
if cached is None or cached < updated_at:
stale.append(course_id)
return stale
def requeue(course_ids, queue):
for cid in course_ids:
queue.push({'course_id': cid, 'reason': 'reconcile'})
渲染队列消费事件,逐课程生成静态 HTML 与 JSON-LD,写对象存储并刷新 CDN。全流程课程页从"运营点发布"到"AI 爬虫可见"的延迟稳定在分钟级。队列的消费端按课程维度加锁,避免同一课程被并行渲染出互相覆盖的版本;渲染产物先写临时对象再原子改名为正式 key,保证爬虫任何时刻读到的都是一个完整页面,不会读到写了一半的 HTML。
三、面向 AI 引擎的出口:预渲染页面与结构化数据
3.1 页面输出原则
给 AI 爬虫的静态页有三条硬性原则:
- 服务端直出完整正文。课程简介、大纲、讲师信息全部在 HTML 里,不依赖前端 JS 拉取;
- 一课一页一实体。每门课一个稳定 URL、一个 Course 实体,不搞列表页聚合输出;
- 禁止为爬虫输出与用户页面不同的内容。同一 URL 对人和对爬虫返回一致的正文,只允许在"是否需要交互壳"上有差异。
3.2 Course Schema 的组装
JSON-LD 从主数据直接组装,注意几个课程场景特有的字段:hasCourseInstance 描述开班信息,provider 挂机构,offers 挂价格。示例是渲染服务里 Schema 组装的核心片段:
def build_course_ld(course: dict) -> dict:
ld = {
"@context": "https://schema.org",
"@type": "Course",
"name": course["title"],
"description": course["summary"],
"provider": {
"@type": "Organization",
"name": course["org_name"],
"url": course["org_url"],
},
"url": f'https://edu.example.com/course/{course["slug"]}',
"offers": {
"@type": "Offer",
"price": str(course["price"] / 100), # 库存单位是分
"priceCurrency": "CNY",
"availability": (
"https://schema.org/InStock"
if course["on_sale"] else "https://schema.org/Discontinued"
),
},
"inLanguage": "zh-CN",
}
if course.get("chapters"):
ld["syllabusSections"] = [
{"@type": "Syllabus", "name": ch["title"], "position": i + 1}
for i, ch in enumerate(course["chapters"])
]
if course.get("next_open_at"):
ld["hasCourseInstance"] = {
"@type": "CourseInstance",
"courseMode": "online",
"startDate": course["next_open_at"].strftime("%Y-%m-%d"),
}
return ld
组装函数只有一个数据来源参数,这就是"投影"的含义:官网接口、小程序接口和这个函数读的是同一张表、同一行数据,字段口径天然一致。
3.3 两个容易被忽略的配套输出
除了页面本身,还有两个低成本高收益的配套。一是在静态站根目录部署 llms.txt,把课程分类、主力课程入口与更新说明按规范列出,相当于给 AI 爬虫一份导航图;渲染服务每次批量更新后同步重新生成,维护成本几乎为零。二是给静态页统一输出 Cache-Control 与 Last-Modified,配合 sitemap 的 lastmod 让引擎用条件请求校验页面,爬虫的重复抓取量降下来后,服务器压力和抓取配额的利用效率都会改善。这两项在方案评审时经常被当成"锦上添花"砍掉,但从我们后见之明的数据看,AI 爬虫的抓取频次与页面被索引的速度,和这两个信号的相关性不低,建议保留。
四、多端一致性验证:别靠肉眼
管道上线后最容易放松的环节是一致性验证。课程价格在小程序显示 199、静态页 Schema 里却是 299,这种事故不需要架构问题也会发生——运营改了主库但渲染队列积压、CDN 缓存没刷干净,都可能导致。我们把验证做成了每日定时任务:
def daily_check(sample=50):
courses = pick_random_published_courses(sample)
for c in courses:
web = web_api_price(c['id']) # 官网接口
mini = mini_api_price(c['id']) # 小程序接口
static = parse_schema_price(c['url']) # 静态页 JSON-LD
prices = {'web': web, 'mini': mini, 'static': static}
if len(set(prices.values())) > 1:
alert(f"价格不一致 course={c['id']} {prices}")
三个渠道各取一次价格,发现不一致立刻告警并触发该课程的重新渲染。这套检查上线第一个月抓到了四次缓存刷新遗漏,都是分钟级的窗口期问题,靠肉眼看永远发现不了。
除了价格,告警字段按渠道特性分了优先级:静态页重点校验 Schema 的必填字段与价格;官网接口重点校验上下架状态与排期;小程序接口重点校验SKU与库存。三份检查共用同一批抽样课程,每天跑一轮,全量问题靠月度全量扫描兜底。经验是:一致性检查的告警必须给出"哪一端旧了"的判断依据(比较各端的 updated_at 快照),否则值班同学每次都要逐端排查,告警很快就会被习惯性忽略。
五、上线后的效果与成本
改造覆盖平台 1400 余门课程,改造前后各观察一个月:
| 指标 | 改造前 | 改造后 | 说明 |
|---|---|---|---|
| 课程页 AI 爬虫日均抓取 | 310 次 | 2,180 次 | 静态页直出后爬虫抓取意愿显著提升 |
| 课程名在 AI 回答中被引用/周 | 9 次 | 63 次 | 主要增量来自大纲与价格字段 |
| 三端内容不一致工单/月 | 17 单 | 1 单 | 单一数据源的直接收益 |
| 新课上线到全端可见 | 2~4 小时 | 5 分钟内 | 事件驱动替代人工多端发布 |
| 渲染服务月成本 | — | 约一台 2C4G | 静态化按变更触发,资源占用低 |
成本侧的结论比较反直觉:整个改造里最贵的不是渲染服务,而是字段口径收敛——把三个渠道各自为政的"简介""大纲""价格"定义统一回主库,涉及历史数据清洗和两端老接口的兼容,占了工期的一半。架构本身反而简单,难的是让所有端愿意放弃自己的"本地版本"。
还要说明一个边界:小程序端的内容目前 AI 引擎基本抓不到,做 GEO 时它不承担被引用的职责,但依然是管道的一端——因为学员在微信里搜课程、分享课程的动作都发生在小程序,内容一致性的价值在用户体验而不是爬虫可见性。把"小程序不参与 AI 引用"写进方案,可以避免后续被反复追问"为什么小程序也要走同一份数据"这类问题。
六、误区澄清与趋势
两个误区值得点名。一是"给 AI 单独做一套站":独立输出一份内容给爬虫,与用户页面不一致,属于自建镜像的灰色操作,被发现后整站信任度都会受损,正确做法永远是一份数据投影出一致内容。二是"上了 Schema 就等于做了 GEO":Schema 只是投影的一种格式,主数据质量不行(简介空洞、大纲是图片、价格口径混乱),投影出来一样没人引用。补一个执行层的提醒:事件驱动的渲染队列必须做幂等——同一门课短时间被连续保存三次,应该只渲染最后一次;实现上给每条消息带课程当前 updated_at,消费时校验版本号,旧消息直接丢弃,否则高峰期运营集中改价会把队列打出一串无效渲染。
趋势上,AI 引擎对课程类查询正在从"推荐平台"走向"推荐具体课程与班期",hasCourseInstance 这类细粒度字段的引用权重会持续上升。技术上,多端分发架构的本质没有变化:单一数据源、事件驱动投影、全链路一致性校验,这三件事做扎实,新增任何渠道——包括未来的 AI 对话式分发——都只是多加一个出口的问题。如果你的课程平台也在做类似改造,欢迎在评论区讨论事件幂等与缓存刷新的实现细节。
关键词:GEO 生成式引擎优化、AI 优化 AIO、Course Schema、hasCourseInstance、JSON-LD 结构化数据、事件驱动架构、多端内容分发、静态化预渲染