阶段1:服务化骨架(FastAPI + Playwright worker + 任务队列)

- 新增 runtime.py:抽离共享核心 launch_and_prepare / dispatch_task / run_heartbeat /
  probe_* / launch_anneng / RuntimeContext,常量 SITES_CONFIG 等;main_router 复用
- 新增 server.py:FastAPI(主线程)+ Playwright worker(独立线程)+ 任务队列;
  API:POST/GET /tasks、GET /status、GET /data/{file}(防路径穿越)
- state_store:加 task_history 表 + create/update/get/list 接口
- 5 站点 with_retry 改为返回 True/False,供 dispatch_task 判成败
- main_router:重写为复用 runtime 的交互模式(行为不变)
- requirements:加 fastapi、uvicorn
- CLAUDE.md:补充运行模式与共享核心架构说明

线程模型:主线程 FastAPI 不碰 Playwright,worker 线程独占 page,经 Queue + SQLite 通信。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-07-17 00:04:39 +08:00
parent a5ceb42ac1
commit 66bd8af421
11 changed files with 882 additions and 493 deletions

View File

@@ -1,142 +1,35 @@
# main_router.py
#
# 交互菜单模式入口(调试 / 人工操作)。核心 Playwright 管理、任务派发、心跳
# 已抽到 runtime.py 共享;本文件只保留交互菜单与自动化测试。
# 服务模式(常驻 + FastAPI 接收指令)见 server.py。
import os
import queue
import socket
import subprocess
import threading
import time
import urllib.request
from datetime import datetime
import yaml
from playwright.sync_api import sync_playwright
from paths import DOWNLOAD_DIR, CONFIG_PATH
from paths import CONFIG_PATH
from runtime import (
APP_SITES,
HEARTBEAT_INTERVAL,
dispatch_task,
launch_and_prepare,
run_heartbeat,
)
# 阶段0状态持久化站点登录态 / 数据态,心跳刷新;重启不丢)
import state_store
# 导入抽离出去的各个网点模块
# 各站点模块(自动化测试 + 比对用;任务派发在 runtime
import site_shunxin
import site_baishi
import site_zto
import site_yunda
import site_anneng
# 应到未到比对(全站点,离线处理 downloads/ 下的应到/实到数据)
import expected_undelivered
# 各网页站点首页 URL 统一取自对应站点模块的 HOME_URL单一来源不在两处维护
SITES_CONFIG = {
"顺心": site_shunxin.HOME_URL,
"百世": site_baishi.HOME_URL,
"中通": site_zto.HOME_URL,
"韵达": site_yunda.HOME_URL,
}
# ====================================================================
# 站点就绪特征:每个站点登录成功进入工作台后的标志性控件
# ====================================================================
READY_SELECTORS = {
"顺心": 'h1:has-text("盟商门户网")',
"百世": 'h1[title="百世快运"]',
"中通": '.logo:has-text("网点版")',
"韵达": '.el-menu-item:has-text("首页")',
}
# 安能是 Electron 桌面应用(不是 Playwright 打开的网页),需要单独启动。
APP_SITES = {"安能"}
# 阶段0心跳间隔——各站就绪后每隔此时长探测一次登录态/数据态
HEARTBEAT_INTERVAL = 30
# 各站最终数据文件名(用于探测"数据是否已跑出来");百世为单流程,仅应到未到
DATA_FILENAMES = {
"顺心": {"expected": "顺心-应到货物数据.xlsx", "actual": "顺心-实到货物数据.xlsx"},
"中通": {"expected": "中通-应到货物数据.xlsx", "actual": "中通-实到货物数据.xlsx"},
"韵达": {"expected": "韵达-应到货物数据.xlsx", "actual": "韵达-实到货物数据.xlsx"},
"安能": {"expected": "安能-应到货物数据.xlsx", "actual": "安能-实到货物数据.xlsx"},
"百世": {"expected": "百世-应到未到货物数据.xlsx", "actual": ""},
}
def probe_site_login(site_name, pages_map):
"""探测单站是否登录(复用就绪轮询判据)。任何异常一律返回 False。
只在主线程调用Playwright sync 对象绑定主线程)。
"""
try:
if site_name not in pages_map:
return False
if site_name == "安能":
return site_anneng.anneng_ready()
if site_name == "顺心":
return all(
pg.locator(READY_SELECTORS["顺心"]).is_visible(timeout=500)
for pg in pages_map["顺心"]
)
return (
pages_map[site_name]
.locator(READY_SELECTORS[site_name])
.is_visible(timeout=500)
)
except Exception:
return False
def probe_data_file(site_name, kind):
"""探测单站应到/实到数据文件是否存在且为今天。返回 (is_today: bool, mtime_str)。"""
fname = DATA_FILENAMES.get(site_name, {}).get(kind, "")
if not fname:
return (False, "")
path = os.path.join(DOWNLOAD_DIR, fname)
if not os.path.exists(path):
return (False, "")
dt = datetime.fromtimestamp(os.path.getmtime(path))
is_today = dt.date() == datetime.now().date()
return (is_today, dt.strftime("%Y-%m-%d %H:%M:%S"))
def _find_free_port():
"""让操作系统分配一个空闲端口,避免固定端口冲突。"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _wait_cdp_up(port, timeout=60.0):
"""轮询直到 CDP 调试端口就绪(应用启动需要时间)。"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(
f"http://localhost:{port}/json/version", timeout=2
) as resp:
if resp.status == 200:
return True
except Exception:
pass
time.sleep(1)
return False
def launch_anneng(app_path):
"""以调试模式启动安能 Electron 应用(自动选取空闲端口),返回子进程对象。
启动后请在应用内手动登录;就绪状态由 run_multi_site_daemon 的就绪轮询判断。
"""
port = _find_free_port()
print(f">> 以调试模式启动【安能】应用(端口 {port}{app_path}")
proc = subprocess.Popen([app_path, f"--remote-debugging-port={port}"])
site_anneng.set_cdp_port(port)
if not _wait_cdp_up(port):
raise RuntimeError(
f"安能应用调试端口 {port} 未就绪——可能应用已在运行(单实例),"
"请先关闭已有的安能窗口再试"
)
return proc
def run_undelivered_compare():
"""应到未到比对(全站点):调用 expected_undelivered读 downloads/ 下的应到/实到
@@ -279,399 +172,174 @@ def _print_test_report(results):
print("====================================================")
def run_multi_site_daemon():
"""多站点自动化主控流程"""
# ====================================================================
# 交互菜单模式
# ====================================================================
# 1. 读取配置文件
def _read_debug_config():
"""读 config.yaml 的 debug 段,返回 (debug_mode, debug_target)。"""
debug_mode = False
debug_target = ""
anneng_app_path = ""
try:
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = yaml.safe_load(f) or {}
debug_mode = (config.get("debug", {}) or {}).get("enabled", False)
debug_target = (config.get("debug", {}) or {}).get("target_site", "")
anneng_app_path = (config.get("anneng", {}) or {}).get("app_path", "")
except Exception as e:
print(f"⚠️ 读取 config.yaml 异常,将使用全量模式启动: {e}")
return debug_mode, debug_target
# 动态确定需要挂载启动的网页站点安能Electron 应用)单独标记
anneng_active = False
if debug_mode:
if debug_target in SITES_CONFIG:
print(f"\n🛠️ 【调试模式】仅加载目标站点: [{debug_target}]")
active_sites = {debug_target: SITES_CONFIG[debug_target]}
elif debug_target == "安能":
print(f"\n🛠️ 【调试模式】仅加载目标站点: [安能]")
active_sites = {}
anneng_active = True
else:
active_sites = dict(SITES_CONFIG)
anneng_active = True
else:
active_sites = dict(SITES_CONFIG)
anneng_active = True
if anneng_active and not anneng_app_path:
print("⚠️ 已启用安能但 config.yaml 未配置 anneng.app_path将跳过安能。")
anneng_active = False
# 菜单编号 → 任务规格dispatch_task 消费)
CHOICE_TO_TASK = {
"1": {"site": "顺心", "kind": "expected"},
"2": {"site": "顺心", "kind": "actual"},
"3": {"site": "百世", "kind": "undelivered"},
"4": {"site": "中通", "kind": "expected"},
"5": {"site": "中通", "kind": "actual"},
"6": {"site": "韵达", "kind": "expected"},
"7": {"site": "韵达", "kind": "actual"},
"10": {"site": "安能", "kind": "expected"},
"11": {"site": "安能", "kind": "actual"},
"9": {"site": "__compare__", "kind": "compare"},
}
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context(viewport={"width": 1920, "height": 1080})
pages_map = {}
def _interactive_menu_loop(ctx):
"""交互菜单循环input 后台线程 + _await_command + dispatch_task + 心跳 + 状态盘。
print("\n====================================================")
print("【启动】正在打开各站点页面...")
print("====================================================")
所有 page 操作经 runtime主线程满足 Playwright sync 线程安全。
"""
pages_map = ctx.pages_map
sites_to_watch = ctx.sites_to_watch
for site_name, url in active_sites.items():
if site_name == "顺心":
# 顺心:两个归属地账号在同一窗口各开一个标签页(顺心站点支持
# 同浏览器双账号并存pages_map["顺心"] 存为 page 列表。
sx_pages = []
for acct in range(1, 3):
print(f">> 正在启动【顺心】账号{acct}标签页: {url}")
sx_page = context.new_page()
sx_page.goto(url)
sx_pages.append(sx_page)
pages_map["顺心"] = sx_pages
else:
print(f">> 正在启动【{site_name}】页面: {url}")
page = context.new_page()
page.goto(url)
pages_map[site_name] = page
def is_site_ready(site_name):
if site_name not in pages_map:
print(f"\n🚫 站点 [{site_name}] 未加载(当前为调试模式),已跳过。")
return False
return True
# 安能:以调试模式启动 Electron 应用(非 Playwright 网页)
anneng_proc = None
if anneng_active:
try:
anneng_proc = launch_anneng(anneng_app_path)
pages_map["安能"] = True # 哨兵:表示已启动(无 Playwright page 对象)
except Exception as e:
print(f"⚠️ 启动安能应用失败,已跳过安能:{e}")
anneng_active = False
print("\n====================================================")
print("【登录检测】正在准备各站点登录...")
print("====================================================")
# 针对支持纯代码自动登录的站点,在此处前置注入登录事件
if "韵达" in pages_map:
try:
pages_map["韵达"].bring_to_front()
site_yunda.yunda_login(pages_map["韵达"])
except Exception as e:
print(f" ⚠️ 韵达前置自动登录模块发生波动: {e}")
# ====================================================================
# 就绪轮询 (Ready Guard Polling)
# 自动识别各站点登录完成状态,无需手动回车
# ====================================================================
ready_status = {site: False for site in active_sites.keys()}
if anneng_active:
ready_status["安能"] = False
print("\n>> 正在轮询各站点就绪状态 (自动登录或手动登录均可)...")
while not all(ready_status.values()):
for site_name in list(ready_status.keys()):
if ready_status[site_name]:
continue
try:
if site_name == "安能":
# 安能走 CDP 判断主页是否就绪(连不上返回 False不抛异常
ok = site_anneng.anneng_ready()
elif site_name == "顺心":
# 顺心双账号:两个 page 都进主页才算就绪
ok = all(
pg.locator(READY_SELECTORS["顺心"]).is_visible(timeout=500)
for pg in pages_map["顺心"]
)
else:
# 0.5 秒轻量探测,避免阻塞主循环
ok = (
pages_map[site_name]
.locator(READY_SELECTORS[site_name])
.is_visible(timeout=500)
)
if ok:
ready_status[site_name] = True
print(f" ✅ 【{site_name}】已检测到主页,登录就绪。")
except Exception:
pass
pending_sites = [s for s, ready in ready_status.items() if not ready]
if pending_sites:
print(
f" ⏳ 等待以下站点完成登录: [{', '.join(pending_sites)}] ... "
"(请在浏览器/应用中操作)"
)
time.sleep(3) # 等待 3 秒后进行下一轮检查
print("\n====================================================")
print("【准备】所有站点已就绪,正在清理初始弹窗...")
print("====================================================")
# 顺心:处理初始弹窗(双账号两个 page 各处理一遍)
if "顺心" in pages_map:
for acct, sx_page in enumerate(pages_map["顺心"], start=1):
try:
sx_page.bring_to_front()
print(f">> 正在处理【顺心】账号{acct}弹窗与遮罩...")
sx_page.locator("a").nth(4).click(timeout=2000)
sx_page.wait_for_timeout(500)
sx_page.get_by_role("button", name="Close").click(timeout=2000)
sx_page.wait_for_timeout(500)
sx_page.get_by_role("button", name="不再询问").click(timeout=2000)
print(f" ✅ 【顺心】账号{acct}初始弹窗处理完成。")
except Exception:
pass # 环境可能很干净没有弹窗,无视报错
# 百世:循环关闭初始弹窗
if "百世" in pages_map:
try:
bs_page = pages_map["百世"]
bs_page.bring_to_front()
print(">> 正在处理【百世】阅读完毕与关闭按钮...")
for round_idx in range(4):
handled_any = False
try:
read_btns = bs_page.locator("button:has-text('阅读完毕')")
if read_btns.count() > 0:
for i in range(read_btns.count()):
if read_btns.nth(i).is_visible(timeout=500):
read_btns.nth(i).click()
handled_any = True
except Exception:
pass
try:
if bs_page.locator("button:has-text('关 闭')").is_visible(
timeout=500
):
bs_page.locator("button:has-text('关 闭')").click()
handled_any = True
except Exception:
pass
if not handled_any:
break
bs_page.wait_for_timeout(800)
print(" ✅ 【百世】初始弹窗处理完成。")
except Exception:
pass
if "中通" in pages_map:
print(" ✅ 【中通】已就绪。")
if "韵达" in pages_map:
print(" ✅ 【韵达】已就绪。")
if "安能" in pages_map:
print(" ✅ 【安能】已就绪。")
def is_site_ready(site_name):
if site_name not in pages_map:
print(f"\n🚫 站点 [{site_name}] 未加载(当前为调试模式),已跳过。")
return False
return True
# ==================================================================
# 阶段0后台心跳 + 状态持久化
# input 放后台线程(只读 stdin不碰 Playwright主线程在 _await_command
# 里轮询命令队列并定期跑心跳;所有 page 操作仍在主线程,满足 Playwright
# sync 的线程安全。
# ==================================================================
state_store.init_db()
sites_to_watch = list(ready_status.keys())
# 就绪轮询刚通过 → 各站视为已登录写一次初值,后续心跳校正
for _site in sites_to_watch:
state_store.set_login_state(_site, True)
last_heartbeat = 0.0
def _run_heartbeat():
"""一轮心跳:探测各站登录态 + 数据文件,写状态库;登录态变化时提示。"""
nonlocal last_heartbeat
prev = state_store.get_all_status()
for site_name in sites_to_watch:
logged_in = probe_site_login(site_name, pages_map)
prev_login = prev.get(site_name, {}).get("login_state")
state_store.set_login_state(site_name, logged_in)
now_login = state_store.LOGIN_IN if logged_in else state_store.LOGIN_OUT
if prev_login and prev_login not in (
now_login,
state_store.LOGIN_UNKNOWN,
):
print(
f"\n ⚠️【{site_name}】登录态变化: {prev_login}{now_login}"
)
for kind in ("expected", "actual"):
ready, gen_at = probe_data_file(site_name, kind)
state_store.set_data_state(site_name, kind, ready, gen_at)
last_heartbeat = time.monotonic()
def _print_status_board():
"""打印各站登录态 / 数据就绪状态盘(菜单 [12])。"""
print("\n====================== 站点状态盘 ======================")
status = state_store.get_all_status()
if not status:
print(" (暂无状态记录)")
print("======================================================")
return
login_text = {
state_store.LOGIN_IN: "✅ 已登录",
state_store.LOGIN_OUT: "❌ 未登录",
state_store.LOGIN_UNKNOWN: "❔ 未知",
}
for site_name in sites_to_watch:
s = status.get(site_name)
if not s:
continue
login_mark = login_text.get(s["login_state"], s["login_state"])
exp = (
f"应到{'' if s['expected_ready'] else ''} "
f"{s['expected_generated_at'] or ''}"
)
act = (
f"实到{'' if s['actual_ready'] else ''} "
f"{s['actual_generated_at'] or ''}"
)
print(
f"{site_name}{login_mark} | {exp} | {act} "
f"| 探测于 {s['login_checked_at']}"
)
print("======================================================")
command_queue = queue.Queue()
def _input_loop():
"""后台线程:读用户输入塞进队列。不碰任何 Playwright 对象。"""
while True:
try:
command_queue.put(input())
except EOFError:
return
threading.Thread(target=_input_loop, daemon=True).start()
def _await_command():
"""等待一条命令(期间跑心跳)。命令到来即返回。"""
nonlocal last_heartbeat
while True:
try:
return command_queue.get(timeout=0.5)
except queue.Empty:
if time.monotonic() - last_heartbeat >= HEARTBEAT_INTERVAL:
_run_heartbeat()
last_heartbeat = 0.0
command_queue = queue.Queue()
def _await_command():
nonlocal last_heartbeat
while True:
print("\n====================================================")
print(" 物流数据下载主菜单 ")
if debug_mode:
print(f" [ 调试模式,仅加载: {debug_target} ]")
print("====================================================")
print(" 模块一:【顺心】数据处理流")
print(" [1] 执行 - 应到货物数据下载")
print(" [2] 执行 - 实到货物数据下载")
print("-" * 52)
print(" 模块二:【百世】数据处理流")
print(" [3] 执行 - 一键提取应到未到异常数据")
print("-" * 52)
print(" 模块三:【中通】数据处理流")
print(" [4] 执行 - 应到货物数据下载")
print(" [5] 执行 - 实到货物数据下载")
print("-" * 52)
print(" 模块四:【韵达】数据处理流")
print(" [6] 执行 - 应到货物数据下载")
print(" [7] 执行 - 实到货物数据下载")
print("-" * 52)
print(" 模块五【安能】数据处理流Electron 应用)")
print(" [10] 执行 - 应到货物数据下载(运单信息)")
print(" [11] 执行 - 实到货物数据下载(网点到件扫描)")
print("-" * 52)
print(" 自动化测试")
print(" [8] 执行 - 全站点下载流程自动化测试 (交叉跑通校验)")
print("-" * 52)
print(" 全局离线数据处理")
try:
return command_queue.get(timeout=0.5)
except queue.Empty:
if time.monotonic() - last_heartbeat >= HEARTBEAT_INTERVAL:
run_heartbeat(ctx)
last_heartbeat = time.monotonic()
def _print_status_board():
print("\n====================== 站点状态盘 ======================")
status = state_store.get_all_status()
if not status:
print(" (暂无状态记录)")
print("======================================================")
return
login_text = {
state_store.LOGIN_IN: "✅ 已登录",
state_store.LOGIN_OUT: "❌ 未登录",
state_store.LOGIN_UNKNOWN: "❔ 未知",
}
for site_name in sites_to_watch:
s = status.get(site_name)
if not s:
continue
login_mark = login_text.get(s["login_state"], s["login_state"])
exp = f"应到{'' if s['expected_ready'] else ''} {s['expected_generated_at'] or ''}"
act = f"实到{'' if s['actual_ready'] else ''} {s['actual_generated_at'] or ''}"
print(
" [9] 执行 - 应到未到比对(全站点汇总,输出 output/应到未到数据.xlsx"
f" {site_name}{login_mark} | {exp} | {act} "
f"| 探测于 {s['login_checked_at']}"
)
print("-" * 52)
print(" 站点状态")
print(" [12] 查看 - 各站登录态 / 数据就绪状态")
print("-" * 52)
print(" [0] 退出系统")
print("====================================================")
print("请输入任务编号并回车: ", end="", flush=True)
choice = _await_command()
print("======================================================")
def _input_loop():
while True:
try:
if choice == "1" and is_site_ready("顺心"):
site_shunxin.shunxin_expected_download(pages_map["顺心"])
elif choice == "2" and is_site_ready("顺心"):
site_shunxin.shunxin_actual_download(pages_map["顺心"])
elif choice == "3" and is_site_ready("百世"):
page = pages_map["百世"]
page.bring_to_front()
site_baishi.baishi_download_undelivered_data(page)
elif choice == "4" and is_site_ready("中通"):
page = pages_map["中通"]
page.bring_to_front()
site_zto.zto_expected_download(page)
elif choice == "5" and is_site_ready("中通"):
page = pages_map["中通"]
page.bring_to_front()
site_zto.zto_actual_download(page)
elif choice == "6" and is_site_ready("韵达"):
page = pages_map["韵达"]
page.bring_to_front()
site_yunda.yunda_expected_download(page)
elif choice == "7" and is_site_ready("韵达"):
page = pages_map["韵达"]
page.bring_to_front()
site_yunda.yunda_actual_download(page)
elif choice == "10" and is_site_ready("安能"):
site_anneng.anneng_expected_download()
elif choice == "11" and is_site_ready("安能"):
site_anneng.anneng_actual_download()
elif choice == "8":
run_automation_test(pages_map)
elif choice == "9":
run_undelivered_compare()
elif choice == "12":
_print_status_board()
elif choice == "0":
print("\n正在关闭浏览器并退出...")
break
else:
if choice not in [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"0",
]:
print("\n⚠️ 无效输入,请查证后回车。")
except Exception as e:
print(f"❌ 任务调度异常: {e}")
command_queue.put(input())
except EOFError:
return
browser.close()
if anneng_proc is not None:
try:
anneng_proc.terminate()
print("已关闭安能应用。")
except Exception:
pass
threading.Thread(target=_input_loop, daemon=True).start()
while True:
print("\n====================================================")
print(" 物流数据下载主菜单 ")
if ctx.debug_mode:
print(f" [ 调试模式,仅加载: {ctx.debug_target} ]")
print("====================================================")
print(" 模块一:【顺心】数据处理流")
print(" [1] 执行 - 应到货物数据下载")
print(" [2] 执行 - 实到货物数据下载")
print("-" * 52)
print(" 模块二:【百世】数据处理流")
print(" [3] 执行 - 一键提取应到未到异常数据")
print("-" * 52)
print(" 模块三:【中通】数据处理流")
print(" [4] 执行 - 应到货物数据下载")
print(" [5] 执行 - 实到货物数据下载")
print("-" * 52)
print(" 模块四:【韵达】数据处理流")
print(" [6] 执行 - 应到货物数据下载")
print(" [7] 执行 - 实到货物数据下载")
print("-" * 52)
print(" 模块五【安能】数据处理流Electron 应用)")
print(" [10] 执行 - 应到货物数据下载(运单信息)")
print(" [11] 执行 - 实到货物数据下载(网点到件扫描)")
print("-" * 52)
print(" 自动化测试")
print(" [8] 执行 - 全站点下载流程自动化测试 (交叉跑通校验)")
print("-" * 52)
print(" 全局离线数据处理")
print(" [9] 执行 - 应到未到比对(全站点汇总,输出 output/应到未到数据.xlsx")
print("-" * 52)
print(" 站点状态")
print(" [12] 查看 - 各站登录态 / 数据就绪状态")
print("-" * 52)
print(" [0] 退出系统")
print("====================================================")
print("请输入任务编号并回车: ", end="", flush=True)
choice = _await_command()
try:
if choice in CHOICE_TO_TASK:
task = CHOICE_TO_TASK[choice]
site = task["site"]
if site == "__compare__" or is_site_ready(site):
status, error = dispatch_task(ctx, task)
if status == state_store.TASK_FAILED:
print(f"❌ 任务失败: {error}")
elif choice == "8":
run_automation_test(pages_map)
elif choice == "12":
_print_status_board()
elif choice == "0":
break
elif choice.strip() != "":
print("\n⚠️ 无效输入,请查证后回车。")
except Exception as e:
print(f"❌ 任务调度异常: {e}")
def run_multi_site_daemon():
"""多站点自动化主控流程(交互菜单模式)。
启动 → 等待各站登录就绪 → 进入交互菜单;退出时关闭浏览器与安能。
"""
debug_mode, debug_target = _read_debug_config()
ctx = launch_and_prepare(debug_mode, debug_target)
try:
_interactive_menu_loop(ctx)
finally:
print("\n正在关闭浏览器并退出...")
ctx.stop()
print("程序已退出。")