feat: 接入广东公需课平台(jsglpt 登录 + jsxx 进度心跳)

- 新增 platforms/gdgxk.py:base64 登录 → ssoItlmsGxkTicket 换票进 jsxx →
  /uc/store/courseRegister 取课程 → updateViewTime1 心跳上报进度(无需真播)
- platforms/__init__.py 注册;配套抓包脚本与自测脚本
- CHANGELOG / OPTIMIZATION / todo 同步,版本升到 0.0.2
This commit is contained in:
study-tool
2026-10-30 22:17:47 +08:00
parent 1a3347f7f9
commit 16346159c2
9 changed files with 482 additions and 2 deletions
+1
View File
@@ -17,6 +17,7 @@
### 新增 ### 新增
- 新增平台「广东公需课」(`platforms/gdgxk.py`):`jsglpt.gds.edu.cn` 账号密码(base64)登录后换 ITLMS 票进 `jsxx.gds.edu.cn`,课程列表读 `/uc/store/courseRegister`;学习只跑 `updateViewTime1` 进度心跳(服务端按 `lastViewTime` 直接记账,不需要真播)。配套 `script/capture-jsglpt.py`(抓包取证)与 `script/test-gdgxk.py`(接口自测)
- 统一日志源 `runtime.log()`:控制台、内存(`/api/log`)、SQLite(`/api/log/history`)同一份内容;新增右上角「系统日志」弹窗查看 `key=system` 的启动/设置类日志 - 统一日志源 `runtime.log()`:控制台、内存(`/api/log`)、SQLite(`/api/log/history`)同一份内容;新增右上角「系统日志」弹窗查看 `key=system` 的启动/设置类日志
- 设置保存、服务启动都写入统一日志;卡片「课程日志」和「系统日志」的「清空」会同时清内存和 SQLite - 设置保存、服务启动都写入统一日志;卡片「课程日志」和「系统日志」的「清空」会同时清内存和 SQLite
- 前端:卡片状态徽标、学习进度、课程勾选、卡片内实时日志、测验 / 设置 / 系统日志弹窗全部保留;新增平台名徽标与空态引导 - 前端:卡片状态徽标、学习进度、课程勾选、卡片内实时日志、测验 / 设置 / 系统日志弹窗全部保留;新增平台名徽标与空态引导
+36
View File
@@ -171,3 +171,39 @@ GET /mod/school/wanpeng/syncSelf?courseCode=<courseCode>
- 控制台没有 `[system]` 行 → 跑的是旧进程(端口被占用时新代码会提示后直接退出),先结束旧 PID 再启动。 - 控制台没有 `[system]` 行 → 跑的是旧进程(端口被占用时新代码会提示后直接退出),先结束旧 PID 再启动。
- 「系统日志」弹窗空白 → `GET /api/log?key=system` 有 lines 就是前端缓存问题,刷新即可;没有 lines 说明还没产生 system 日志,保存一次设置就能看到。 - 「系统日志」弹窗空白 → `GET /api/log?key=system` 有 lines 就是前端缓存问题,刷新即可;没有 lines 说明还没产生 system 日志,保存一次设置就能看到。
## 广东公需课平台接入(jsglpt → jsxx)
**状态**:接口链路已实测跑通(课程列表、播放注册、进度心跳都拿到 `responseCode=00`),待用真实密码登录 + 未学完课程确认端到端。
### 已确认的接口
- 登录:`POST https://jsglpt.gds.edu.cn/loginIn.do`,`loginUser.userName` / `loginUser.password` 都是 **base64**(账号是手机号),`loginUser.loginType=normal`;表单还有 `captcha` 字段,`GET /login/readCaptcha.action` 返回 `"data:image/png;base64,..."` 的 4 位图形验证码(是否强制尚未验证)。
- 换票:`POST /teacher/ssoItlmsGxkTicket.action?verificaCode=true&page=` → `ITLMS-<hex>||<sig>`;带 `?ticket=` 访问 `https://jsxx.gds.edu.cn/uc/store/courseRegister` 即拿到学习平台会话,两个域名的 cookie 要一起保存。
- 课程列表:`GET /uc/store/courseRegister`,页面里是 `showCourse('<课程id>','<课程名>')`。
- 学习页:`GET /study/course/<课程id>?v=1`(服务端渲染、UTF-8),含 `url1`(CDN 直链 MP4)、`t`(总秒数)、`md5Uid`、`/a_<aid>/...` 上报前缀。
- 播放注册:`POST /a_<aid>/study/video/user/updateVideoStatus1`(`_method=PUT&id=<课件id>&md5Uid=<uid>&playbackSessionId=<sid>&playbackTakeover=true`)→ `{time, md5}`。
- 进度心跳:`POST /a_<aid>/study/video/user/<课件id>/updateViewTime1`(`_method=PUT&aid=...&isLimit=false&md5&expires&md5Uid&lastViewTime=<秒>&playbackSessionId`)。
- 已看秒数:`GET /a_<aid>/study/video/user/getViewTime?id=&md5=&expires=&md5Uid=`,`responseData` 直接就是秒数。
### 踩到的坑
- 三个上报接口必须带 **Referer(指向课程页)**,否则 500 且响应体为空。
- `isLimit` 只能传 `false` / `true`,传 `"N"` 会 400。
- 课程列表页、学习页都是 UTF-8(别按 GBK 解,会花屏)。
### 关键结论
进度不靠播放器,`lastViewTime` 直接写服务端。实测把 2640 秒的课报到 2700 秒也被接受,说明可以跳着报、不必真播;默认 `speed=60`、`beat=2`,44 分钟的课约 45 秒报完。课程是否学完看 `getViewTime >= t`。
### 怎么测
1. 「添加账号」平台选「广东公需课」,填手机号 + 密码 → 能拉到课程列表就算登录成功。
2. 选一门**没学完**的课 → 开始学习 → 看「课程日志」里的进度上报行,再回平台页面核对已学时长。
3. 登录若报「平台要求图形验证码」,说明密码登录要过验证码,先改用浏览器登录后导入 cookie,再补 OCR。
### 如果没生效
- 400 → 看 Referer 有没有、`isLimit` 是不是 `false`。
- 500 且空响应 → 少了 Referer,或 `md5Uid` 取错。
- 课程列表为空 → jsxx 会话失效,重新登录(或等 `renew_session` 换票)。
+3
View File
@@ -5,3 +5,6 @@
- 前端补一条自动化回归(Playwright):空态、账号卡片、测验弹窗、设置保存、平台下拉。 - 前端补一条自动化回归(Playwright):空态、账号卡片、测验弹窗、设置保存、平台下拉。
- 构建检查进 CI:`cd web && pnpm install --frozen-lockfile && pnpm typecheck && pnpm build`, - 构建检查进 CI:`cd web && pnpm install --frozen-lockfile && pnpm typecheck && pnpm build`,
并校验 `static/` 与源码同步。 并校验 `static/` 与源码同步。
- 广东公需课:确认 `loginIn.do` 的图形验证码是否强制;若强制,接 OCR 或大模型识图后再自动登录。
- 广东公需课:用一门**未学完**的课实测进度上报,确认平台端「已学时长 / 已完成」状态跟着变。
- 广东公需课:视频弹题与课程考核未接入(第一期只做登录 / 课程列表 / 看课)。
+1 -1
View File
@@ -1,3 +1,3 @@
name: study-tool name: study-tool
version: 0.0.1 version: 0.0.2
description: 东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)多账号学习控制台 description: 东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)多账号学习控制台
+127
View File
@@ -0,0 +1,127 @@
"""广东省教师继续教育平台抓包工具(一次性取证用,不参与运行)。
用法:
python script/capture-jsglpt.py [分钟数]
行为:开一个有头 Chromium(持久 profile,登录态会保留),打开 jsglpt 登录页,
把你在窗口里操作期间产生的 XHR/fetch 全量落到 data/capture-jsglpt-<时间>.jsonl,
同时每 3 秒把 cookie 快照写到 data/capture-jsglpt-cookies.json。
"""
import json
import os
import sys
import time
from playwright.sync_api import sync_playwright
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA = os.path.join(ROOT, "data")
LOGIN = "https://jsglpt.gds.edu.cn/login.jsp"
MINUTES = float(sys.argv[1]) if len(sys.argv) > 1 else 40
os.makedirs(DATA, exist_ok=True)
STAMP = time.strftime("%Y%m%d-%H%M%S")
JSONL = os.path.join(DATA, "capture-jsglpt-%s.jsonl" % STAMP)
COOKIES = os.path.join(DATA, "capture-jsglpt-cookies.json")
PROFILE = os.path.join(DATA, ".gd-profile")
out = open(JSONL, "a", encoding="utf-8")
seen = 0
def emit(rec):
global seen
seen += 1
rec["n"] = seen
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
out.flush()
print(" %-4s %s %s -> %s" % (rec.get("rt", "doc"), rec.get("method", ""),
rec.get("url", "")[:150], rec.get("status", "")), flush=True)
def on_response(r):
try:
req = r.request
rt = req.resource_type
if rt not in ("xhr", "fetch", "document"):
return
h = {}
try:
h = dict(r.headers or {})
except Exception:
pass
body = ""
# 只留文本类响应,视频/图片跳过
if not (r.url or "").lower().split("?")[0].endswith((".mp4", ".m3u8", ".ts", ".png", ".jpg", ".gif", ".woff", ".woff2", ".css", ".js")):
try:
body = r.text()[:8000]
except Exception:
body = "<binary>"
emit({
"t": time.strftime("%H:%M:%S"),
"rt": rt,
"method": req.method,
"url": r.url,
"req_headers": dict(req.headers or {}),
"req": (req.post_data or "")[:4000],
"status": r.status,
"resp_headers": h,
"resp": body,
})
except Exception:
pass
def main():
print("抓包输出:%s" % JSONL, flush=True)
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
PROFILE, headless=False,
args=["--disable-blink-features=AutomationControlled"],
viewport={"width": 1440, "height": 900},
)
hooked = set()
def hook(page):
if id(page) in hooked:
return
hooked.add(id(page))
page.on("response", on_response)
print("[页面] %s" % (page.url or "about:blank"), flush=True)
ctx.on("page", hook)
pg = ctx.pages[0] if ctx.pages else ctx.new_page()
hook(pg)
try:
pg.goto(LOGIN, wait_until="domcontentloaded", timeout=60000)
except Exception as e:
print("打开登录页失败:%s" % e, flush=True)
last = 0.0
t0 = time.time()
while time.time() - t0 < MINUTES * 60:
try:
now = time.time()
if now - last > 3:
last = now
with open(COOKIES, "w", encoding="utf-8") as f:
json.dump(ctx.cookies(), f, ensure_ascii=False, indent=1)
ctx.pages[0].wait_for_timeout(1000)
except Exception:
# 用户把所有页面关掉了,收工
break
try:
with open(COOKIES, "w", encoding="utf-8") as f:
json.dump(ctx.cookies(), f, ensure_ascii=False, indent=1)
except Exception:
pass
try:
ctx.close()
except Exception:
pass
out.close()
print("抓包结束:%d 条 → %s" % (seen, JSONL), flush=True)
if __name__ == "__main__":
main()
+41
View File
@@ -0,0 +1,41 @@
"""广东公需课自测:用浏览器抓到的 cookie 走一遍 列表 → 发现 → 心跳。
用法:PYTHONPATH=src python script/test-gdgxk.py [课件id]
"""
import json
import sys
import uuid
from study_tool.platforms import gdgxk
cid = sys.argv[1] if len(sys.argv) > 1 else ""
cookies = json.load(open("data/capture-jsglpt-cookies.json", encoding="utf-8"))
s = gdgxk.new_session(cookies)
courses = gdgxk.list_courses(s)
print("课程数:", len(courses))
for c in courses:
print(" ", c["plan_id"], c["name"])
if not cid and courses:
cid = courses[0]["plan_id"]
d = gdgxk.discover(s, cid, "")
print("\n课程:", d["course"])
print("进度:", d["progress"])
print("视频:", d["info"])
cw = d["courseware"][0]
print("课件:", cw["id"], "| 总长", cw["total"], "| 已看", cw["viewTime"])
sid = uuid.uuid4().hex
sign = gdgxk._sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, cw["uid"])
print("\n播放注册签名:", sign)
print("服务端已看秒数:", gdgxk._view_time(s, cw, cw["uid"], sign))
r = gdgxk._xhr(s, "POST",
gdgxk.X + "/" + cw["aid"] + "/study/video/user/" + cw["id"] + "/updateViewTime1",
data={"_method": "PUT", "aid": cw["aid"], "isLimit": "false", "md5": sign["md5"],
"expires": sign["time"], "md5Uid": cw["uid"],
"lastViewTime": str(min(cw["viewTime"] + 60, cw["total"])),
"playbackSessionId": sid})
print("\n心跳响应:", r.status_code, r.text[:300])
+1
View File
@@ -12,4 +12,5 @@ from study_tool.platforms.base import Platform # noqa: F401
def load_builtin(): def load_builtin():
"""导入内置平台,触发注册。幂等,app 启动时调用一次。""" """导入内置平台,触发注册。幂等,app 启动时调用一次。"""
from study_tool.platforms import gdgxk # noqa: F401
from study_tool.platforms import guanyixue # noqa: F401 from study_tool.platforms import guanyixue # noqa: F401
+271
View File
@@ -0,0 +1,271 @@
"""广东公需课:jsglpt 登录 → jsxx 学习。
登录在 `jsglpt.gds.edu.cn`(账号 / 密码都是 base64),成功后用
`ssoItlmsGxkTicket.action` 换 ITLMS 票,带票进 `jsxx.gds.edu.cn` 拿学习会话;
两个域名的 cookie 都留在同一个 requests.Session 里。
学习页是服务端渲染的老系统,视频是 CDN 直链 MP4,进度不靠播放器,靠
`updateViewTime1` 心跳上报(60 秒一次,`lastViewTime` 是已看秒数),
`viewTime` 追平视频总长就算学完,所以这里只跑心跳、不真播。
"""
import base64
import json
import re
import time
import uuid
import requests
from study_tool import runtime
from study_tool.platforms import registry
from study_tool.platforms.base import Platform
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36")
G = "https://jsglpt.gds.edu.cn"
X = "https://jsxx.gds.edu.cn"
PLATFORM_ID = "gdgxk"
def _xhr(s, method, url, **kw):
"""这些上报接口必须带 Referer(指向课程页),否则直接 500。"""
kw.setdefault("headers", {})["X-Requested-With"] = "XMLHttpRequest"
return s.request(method, url, timeout=30, **kw)
def _expired(msg):
"""引擎认的是同一个 SessionExpired,这里延迟取避免循环 import。"""
from study_tool.engine import SessionExpired
return SessionExpired(msg)
def _b64(text):
return base64.b64encode(str(text).encode("utf-8")).decode()
def new_session(cookies=None):
s = requests.Session()
s.headers.update({"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"})
for c in (cookies or []):
if "gds.edu.cn" not in (c.get("domain") or ""):
continue
s.cookies.set(c["name"], c.get("value") or "",
domain=(c.get("domain") or "").lstrip("."),
path=c.get("path") or "/")
return s
def dump_cookies(s):
return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path}
for c in s.cookies]
def _sso(s):
"""jsglpt 会话换 ITLMS 票,再把票交给 jsxx 换学习会话。"""
r = s.post(G + "/teacher/ssoItlmsGxkTicket.action?verificaCode=true&page=", timeout=30)
ticket = (r.text or "").strip()
if not ticket.startswith("ITLMS-"):
raise _expired("取 ITLMS 票失败:%s" % (ticket[:80] or "空响应"))
q = s.get(X + "/uc/store/courseRegister?ticket=" + requests.utils.quote(ticket), timeout=30)
if "showCourse" not in q.text and "我的课程" not in q.text:
raise _expired("带票进学习平台失败,可能票已失效")
return s
def login(username, password):
s = new_session()
s.get(G + "/login.jsp", timeout=30)
data = {
"loginUser.userName": _b64(username),
"loginUser.loginType": "normal",
"loginUser.password": _b64(password),
"captcha": "",
}
r = s.post(G + "/loginIn.do", data=data, allow_redirects=False, timeout=30)
if r.status_code != 302 or "/default.jsp" not in (r.headers.get("location") or ""):
raise RuntimeError("登录失败:账号或密码不对,或平台要求图形验证码(HTTP %s)" % r.status_code)
_sso(s)
return s, profile(s) or username
def renew_session(s):
try:
_sso(s)
return True
except Exception:
return False
def profile(s):
html = s.get(G + "/teacher/index.do", timeout=30).text
m = re.search(r"你好[,,]?\s*([^\s<]+)\s*老师", re.sub(r"\s+", " ", html))
if m:
return m.group(1)
m = re.search(r'class="user"[^>]*>\s*<[^>]*>\s*([^\s<]{2,20})', html)
return m.group(1) if m else ""
def list_courses(s):
html = s.get(X + "/uc/store/courseRegister", timeout=30).text
out, seen = [], set()
for cid, name in re.findall(r"showCourse\('([^']+)','([^']*)'\)", html):
if cid in seen:
continue
seen.add(cid)
out.append({"plan_id": cid, "project_id": "", "name": name.strip(),
"in_study": True, "platform": PLATFORM_ID})
if not out:
if "loginBtn" in html or "showLogin" in html:
raise _expired("学习平台会话已失效,请重新登录")
return []
return out
def parse_course(html):
"""从学习页里抠出心跳需要的一切:aid、课件 id、md5Uid、总长、已看秒数。"""
d = {}
m = re.search(r"/a_([0-9a-f]{32})/study/video/user/", html)
if not m:
raise RuntimeError("学习页里没有找到课件上报路径,页面结构可能变了")
d["aid"] = "a_" + m.group(1)
m = re.search(r"updateVideoStatus1'[\s\S]{0,200}?id=([0-9a-f]{32})", html) \
or re.search(r"id=([0-9a-f]{32})&md5Uid=", html)
if not m:
raise RuntimeError("学习页里没有找到课件 id")
d["id"] = m.group(1)
m = re.search(r"md5Uid=([0-9a-f]{64,128})", html)
if not m:
raise RuntimeError("学习页里没有找到 md5Uid")
d["uid"] = m.group(1)
m = re.search(r"\bt\s*=\s*'(\d+)'", html)
d["total"] = int(m.group(1)) if m else 0
m = re.search(r"url1\s*=\s*'([^']+)'", html)
d["video"] = m.group(1) if m else ""
d["title"] = (re.search(r"<title>([^<]*)</title>", html).group(1).strip()
if "<title>" in html else "")
return d
def _sign(s, cw, uid):
"""updateVideoStatus1:注册播放权,换回心跳签名(time / md5)。"""
r = _xhr(s, "POST", X + "/" + cw["aid"] + "/study/video/user/updateVideoStatus1",
data={"_method": "PUT", "id": cw["id"], "md5Uid": uid,
"playbackSessionId": cw["sid"], "playbackTakeover": "true"})
j = r.json() if r.text.strip().startswith("{") else {}
if j.get("responseCode") != "00":
raise RuntimeError("注册播放失败:%s" % (r.text[:120] or "空响应"))
return j["responseData"]
def _view_time(s, cw, uid, sign):
r = _xhr(s, "GET", X + "/" + cw["aid"] + "/study/video/user/getViewTime",
params={"id": cw["id"], "md5": sign.get("md5"), "expires": sign.get("time"),
"md5Uid": uid})
j = r.json() if r.text.strip().startswith("{") else {}
rd = j.get("responseData")
if isinstance(rd, dict):
return int(rd.get("viewTime") or 0)
return int(rd or 0)
def discover(s, plan_id, project_id, name=""):
cid = plan_id
page = X + "/study/course/" + cid
s.headers["Referer"] = page
r = s.get(page + "?v=1", timeout=30)
if "loginBtn" in r.text and "md5Uid" not in r.text:
raise _expired("学习平台会话已失效,请重新登录")
cw = parse_course(r.text)
sid = uuid.uuid4().hex
cw["sid"] = sid
sign = _sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, cw["uid"])
cur = _view_time(s, cw, cw["uid"], sign)
done = cw["total"] > 0 and cur >= cw["total"]
return {
"user": profile(s),
"course": name or cw["title"],
"progress": "%d/%d 秒" % (cur, cw["total"]),
"info": cw["video"],
"courseware": [{"id": cw["id"], "title": cw["title"], "href": page,
"total": cw["total"], "viewTime": cur, "done": done,
"aid": cw["aid"], "uid": cw["uid"]}],
"feedback": [], "test": [],
"platform": PLATFORM_ID, "generation": "gxk",
"plan_id": cid, "project_id": "",
}
def _stopped(key):
with runtime.RUNS_LOCK:
return (runtime.RUNS.get(key) or {}).get("status") == "stopped"
def study(key, s, d, **opts):
"""只跑心跳上报:每 beat 秒把 lastViewTime 往前推 beat*speed 秒。"""
speed = float(opts.get("speed") or 60)
beat = int(opts.get("beat") or 2)
total = len(d["courseware"])
runtime.set_state(key, status="studying", total=total, done=0)
for i, cw in enumerate(d["courseware"], 1):
if _stopped(key):
runtime.log(key, "已按要求停止")
return
if cw.get("done"):
runtime.log(key, "课件 %s 已学完,跳过" % cw["title"])
runtime.set_state(key, done=i)
continue
runtime.log(key, "开始学习:%s(总长 %d 秒,已看 %d 秒)"
% (cw["title"], cw["total"], cw["viewTime"]))
_learn(key, s, cw, speed, beat)
runtime.set_state(key, done=i)
runtime.set_state(key, status="done", done=total)
runtime.log(key, "全部课件心跳上报完成")
def _learn(key, s, cw, speed, beat):
uid, total = cw["uid"], cw["total"]
s.headers["Referer"] = cw.get("href") or X
sid = uuid.uuid4().hex
base = {"_method": "PUT", "aid": cw["aid"]}
sign = _sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, uid)
cur = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
while cur < total:
if _stopped(key):
runtime.log(key, "已按要求停止")
return
time.sleep(beat)
cur = min(total, cur + int(beat * speed))
data = dict(base, isLimit="false", md5=sign.get("md5"), expires=sign.get("time"),
md5Uid=uid, lastViewTime=str(cur), playbackSessionId=sid)
r = _xhr(s, "POST", X + "/" + cw["aid"] + "/study/video/user/" + cw["id"] + "/updateViewTime1",
data=data)
j = r.json() if r.text.strip().startswith("{") else {}
rd = j.get("responseData") or {}
if j.get("responseCode") == "00" and rd.get("md5"):
sign = {"md5": rd["md5"], "time": rd.get("time")}
server = int(rd.get("viewTime") or cur)
else:
server = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
runtime.log(key, "进度上报:本地 %d 秒 / 服务端 %d 秒 / 总长 %d 秒" % (cur, server, total))
if server < cur:
# 服务端没认这个进度,按它的值继续,别把差距越拉越大
cur = server
if cur >= total:
break
runtime.log(key, "课件完成:%s(%d 秒)" % (cw["title"], total))
PLATFORM = registry.register(Platform(
id=PLATFORM_ID,
label="广东公需课",
study=study,
entries=("jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"),
session=new_session,
dump_cookies=dump_cookies,
login=login,
list_courses=list_courses,
discover=discover,
renew_session=renew_session,
profile=profile,
))
+1 -1
View File
@@ -2,7 +2,7 @@
## 待评估:接入广东公需课平台(jsxx.gds.edu.cn) ## 待评估:接入广东公需课平台(jsxx.gds.edu.cn)
**状态**:只沉淀思路,不实现。手上没有公需课账号,接口抓不到,代码不动。 **状态**:已接入,见 `src/study_tool/platforms/gdgxk.py` 与 `OPTIMIZATION.md`;登录 / 课程列表 / 进度心跳已实测,待用未学完课程确认。
### 背景 ### 背景