prepare_course_demo.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. import argparse
  2. import asyncio
  3. import csv
  4. import hashlib
  5. import json
  6. import math
  7. import os
  8. import re
  9. import secrets
  10. import subprocess
  11. import time
  12. from datetime import UTC, datetime, timedelta
  13. from pathlib import Path
  14. from uuid import UUID, uuid4
  15. import httpx
  16. from dotenv import dotenv_values, set_key
  17. from filelock import FileLock
  18. from sqlalchemy import func, select
  19. from sqlalchemy.ext.asyncio import async_sessionmaker
  20. from zhifu.core.config import Settings
  21. from zhifu.core.database import create_engine
  22. from zhifu.modules.after_sales.models import BusinessEvent, ExchangeRule, Ticket
  23. from zhifu.modules.identity.access_models import Principal, StoreGrant
  24. from zhifu.modules.identity.auth import password_hasher
  25. from zhifu.modules.identity.models import Store, Tenant
  26. from zhifu.modules.integrations.models import Connector, ExternalOperation
  27. from zhifu.modules.storefront.models import CatalogProduct
  28. from zhifu.oms.database import oms_engine
  29. from zhifu.oms.models import Credential
  30. ROOT = Path(__file__).resolve().parents[1]
  31. DATA = ROOT / ".work" / "course-demo"
  32. TENANT = UUID("ac700000-2026-4000-8000-000000000001")
  33. STORE = UUID("ac700000-2026-4000-8000-000000000002")
  34. TOKEN_ENV = "ZHIFU_COURSE_OMS_TOKEN"
  35. CONNECTOR_URL = "http://oms:8100"
  36. CUSTOMER_PERMISSIONS = (
  37. "orders:read", "tickets:read", "after_sales:create", "after_sales:confirm",
  38. "after_sales:cancel",
  39. )
  40. def require(condition, message):
  41. if not condition:
  42. raise ValueError(message)
  43. def utc_now():
  44. return datetime.now(UTC).isoformat()
  45. def read_json(path):
  46. return json.loads(path.read_text(encoding="utf-8"))
  47. def protect_file(path):
  48. # Windows 使用当前用户 SID 设置访问权限,凭据文件仅当前用户和 SYSTEM 可读取。
  49. if os.name == "nt":
  50. result = subprocess.run(
  51. ["whoami", "/user", "/fo", "csv", "/nh"],
  52. check=True, capture_output=True, text=True, timeout=10,
  53. )
  54. sid = next(csv.reader(result.stdout.strip().splitlines()))[1]
  55. subprocess.run(
  56. ["icacls", str(path), "/inheritance:r", "/grant:r",
  57. f"*{sid}:(F)", "*S-1-5-18:(F)"],
  58. check=True, capture_output=True, timeout=10,
  59. )
  60. else:
  61. path.chmod(0o600)
  62. def write_json(path, payload, *, private=False):
  63. path.parent.mkdir(parents=True, exist_ok=True)
  64. temporary = path.with_name(f".{path.name}.{uuid4().hex}.new")
  65. # 同目录临时文件与原子替换保证中断后仍能读取完整 JSON;临时文件也使用受限权限。
  66. descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
  67. with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
  68. if private:
  69. protect_file(temporary)
  70. json.dump(payload, stream, ensure_ascii=False, indent=2)
  71. stream.write("\n")
  72. stream.flush()
  73. os.fsync(stream.fileno())
  74. temporary.replace(path)
  75. def session_name(value):
  76. require(
  77. re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,47}", value) is not None,
  78. "课程名称须为 1 至 48 个英文字母、数字、下划线或连字符,并以字母或数字开头",
  79. )
  80. require(
  81. value.upper() not in {"CON", "PRN", "AUX", "NUL"}
  82. and re.fullmatch(r"(?:COM|LPT)[1-9]", value.upper()) is None,
  83. "课程名称不能使用 Windows 保留文件名",
  84. )
  85. return value
  86. def validate_scope(value):
  87. require(
  88. value["tenant_id"] == str(TENANT) and value["store_id"] == str(STORE),
  89. "课程数据仅允许使用固定开发商家和店铺",
  90. )
  91. def validate_url(value):
  92. url = httpx.URL(value)
  93. require(
  94. url.scheme in {"http", "https"} and url.host in {"127.0.0.1", "localhost", "::1"}
  95. and not url.username and not url.password and not url.query and not url.fragment
  96. and url.path in {"", "/"},
  97. "课程接口地址必须为本机 HTTP 或 HTTPS 服务根地址",
  98. )
  99. return str(url).rstrip("/")
  100. def load_manifest(name=None):
  101. path = DATA / "sessions" / f"{session_name(name)}.json" if name else DATA / "active.json"
  102. manifest = read_json(path)
  103. validate_scope(manifest)
  104. session_name(manifest["session"])
  105. require(manifest["schema_version"] == 1, "课程记录版本不受支持")
  106. validate_url(manifest["api_url"])
  107. validate_url(manifest["oms_url"])
  108. require(manifest["order"]["item_id"] == "keyboard-01", "课程订单商品不匹配")
  109. return manifest
  110. def save_manifest(manifest, *, activate=False):
  111. write_json(DATA / "sessions" / f"{manifest['session']}.json", manifest)
  112. active = DATA / "active.json"
  113. if activate or (active.exists() and read_json(active)["session"] == manifest["session"]):
  114. write_json(active, manifest)
  115. def credentials(accounts=None):
  116. path = DATA / "credentials.json"
  117. if path.exists():
  118. value = read_json(path)
  119. validate_scope(value)
  120. require(value["schema_version"] == 1, "课程凭据版本不受支持")
  121. protect_file(path)
  122. return value
  123. require(accounts is not None, "课程凭据不存在,请执行 prepare")
  124. value = {
  125. "schema_version": 1, "tenant_id": str(TENANT), "store_id": str(STORE),
  126. "customer_password": accounts["password"],
  127. "service": {
  128. "principal_id": str(uuid4()), "username": "course-order-sync",
  129. "password": secrets.token_urlsafe(32),
  130. },
  131. "oms_platform_token": secrets.token_urlsafe(32),
  132. "oms_fulfillment_token": secrets.token_urlsafe(32),
  133. }
  134. # 先保存凭据,再创建数据库记录;后续显式重试继续使用相同身份与令牌。
  135. write_json(path, value, private=True)
  136. return value
  137. def prepare_files(args):
  138. accounts = read_json(ROOT / ".work" / "frontend-accounts.json")
  139. validate_scope(accounts)
  140. name = session_name(args.session or datetime.now().strftime("%Y%m%d-%H%M%S-%f"))
  141. path = DATA / "sessions" / f"{name}.json"
  142. secret = credentials(accounts)
  143. if path.exists():
  144. manifest = load_manifest(name)
  145. require(
  146. manifest["api_url"] == args.api_url and manifest["oms_url"] == args.oms_url,
  147. "已有课程接口地址与本次参数不同,请使用原有地址",
  148. )
  149. else:
  150. manifest = {
  151. "schema_version": 1, "session": name, "tenant_id": str(TENANT),
  152. "store_id": str(STORE), "created_at": utc_now(), "status": "PREPARING",
  153. "api_url": args.api_url, "oms_url": args.oms_url,
  154. "customer": {"principal_id": str(uuid4()), "username": f"course-{name}"},
  155. "order": {
  156. "order_id": str(uuid4()), "item_id": "keyboard-01", "product_type": "keyboard",
  157. "available_quantity": 1, "source_version": 1,
  158. "signed_at": (datetime.now(UTC) - timedelta(days=2)).isoformat(),
  159. },
  160. "staff": {name: identifier for name, identifier in accounts["actors"].items()
  161. if name in {"staff01", "supervisor01", "admin01"}},
  162. }
  163. save_manifest(manifest)
  164. return manifest, secret
  165. async def ensure_principal(session, actor, password, kind, permissions):
  166. identifier = UUID(actor["principal_id"]).bytes
  167. existing = await session.get(Principal, identifier, with_for_update=True)
  168. if existing is None:
  169. collision = await session.scalar(select(Principal).where(
  170. Principal.tenant_id == TENANT.bytes, Principal.username == actor["username"],
  171. ))
  172. require(collision is None, "课程账户名已经被其他身份使用")
  173. session.add(Principal(
  174. id=identifier, tenant_id=TENANT.bytes, username=actor["username"],
  175. password_hash=await asyncio.to_thread(password_hasher.hash, password), kind=kind,
  176. ))
  177. await session.flush()
  178. for permission in permissions:
  179. session.add(StoreGrant(
  180. tenant_id=TENANT.bytes, store_id=STORE.bytes,
  181. principal_id=identifier, permission=permission,
  182. ))
  183. else:
  184. require(
  185. existing.tenant_id == TENANT.bytes and existing.username == actor["username"]
  186. and existing.kind == kind and existing.status == "ACTIVE",
  187. "已有课程账户身份或状态与课程配置不一致",
  188. )
  189. require(
  190. await asyncio.to_thread(password_hasher.verify, password, existing.password_hash),
  191. "已有课程账户密码与本地受保护凭据不一致",
  192. )
  193. granted = set((await session.scalars(select(StoreGrant.permission).where(
  194. StoreGrant.tenant_id == TENANT.bytes, StoreGrant.store_id == STORE.bytes,
  195. StoreGrant.principal_id == identifier,
  196. ))).all())
  197. require(granted == set(permissions), "已有课程账户授权与课程要求不一致")
  198. async def bootstrap(settings, manifest, secret):
  199. engine = create_engine(settings)
  200. oms = oms_engine()
  201. try:
  202. sessions = async_sessionmaker(engine, expire_on_commit=False)
  203. async with sessions.begin() as session:
  204. tenant = await session.get(Tenant, TENANT.bytes)
  205. store = await session.get(Store, STORE.bytes, with_for_update=True)
  206. require(tenant is not None and tenant.status == "ACTIVE", "开发商家未启用")
  207. require(
  208. store is not None and store.tenant_id == TENANT.bytes and store.status == "ACTIVE",
  209. "开发店铺未启用或不属于指定商家",
  210. )
  211. product = await session.get(CatalogProduct, (TENANT.bytes, STORE.bytes, "keyboard-01"))
  212. require(product is not None and product.status == "ACTIVE", "键盘商品未发布")
  213. rule = await session.scalar(select(ExchangeRule).where(
  214. ExchangeRule.tenant_id == TENANT.bytes, ExchangeRule.store_id == STORE.bytes,
  215. ).order_by(ExchangeRule.version.desc()).limit(1))
  216. require(
  217. rule is not None and not rule.revoked and rule.window_days > 2
  218. and "keyboard" in rule.product_types,
  219. "当前规则不支持签收两天的键盘订单,请检查已发布规则",
  220. )
  221. manifest["rule"] = {"version": rule.version, "window_days": rule.window_days,
  222. "citation": rule.citation}
  223. manifest["product_name"] = product.name
  224. manifest["existing_ticket_count"] = await session.scalar(
  225. select(func.count()).select_from(Ticket).where(
  226. Ticket.tenant_id == TENANT.bytes,
  227. Ticket.order_id == UUID(manifest["order"]["order_id"]).bytes,
  228. )
  229. )
  230. await ensure_principal(
  231. session, manifest["customer"], secret["customer_password"],
  232. "CUSTOMER", CUSTOMER_PERMISSIONS,
  233. )
  234. await ensure_principal(
  235. session, secret["service"], secret["service"]["password"],
  236. "SERVICE", ("orders:sync",),
  237. )
  238. connector = await session.get(Connector, (TENANT.bytes, STORE.bytes))
  239. if connector is None:
  240. session.add(Connector(
  241. tenant_id=TENANT.bytes, store_id=STORE.bytes, source_system="oms",
  242. base_url=CONNECTOR_URL, credential_env=TOKEN_ENV, enabled=True,
  243. ))
  244. session.add(BusinessEvent(
  245. id=uuid4().bytes, tenant_id=TENANT.bytes,
  246. actor_id=UUID(secret["service"]["principal_id"]).bytes,
  247. aggregate_id=STORE.bytes, aggregate_version=1,
  248. event_type="connector.configured", request_id=str(uuid4()),
  249. payload={"credential_env": TOKEN_ENV, "source": "prepare_course_demo"},
  250. occurred_at=datetime.now(UTC).replace(tzinfo=None),
  251. ))
  252. else:
  253. require(
  254. connector.source_system == "oms" and connector.base_url == CONNECTOR_URL
  255. and connector.credential_env == TOKEN_ENV and connector.enabled,
  256. "已有 OMS 连接器配置与课程环境不一致",
  257. )
  258. oms_sessions = async_sessionmaker(oms, expire_on_commit=False)
  259. async with oms_sessions.begin() as session:
  260. for name, can_fulfill in (
  261. ("oms_platform_token", False), ("oms_fulfillment_token", True),
  262. ):
  263. token_hash = hashlib.sha256(secret[name].encode()).digest()
  264. existing = await session.get(Credential, token_hash, with_for_update=True)
  265. if existing is None:
  266. session.add(Credential(
  267. token_hash=token_hash, tenant_id=TENANT.bytes, store_id=STORE.bytes,
  268. can_fulfill=can_fulfill, enabled=True,
  269. ))
  270. else:
  271. require(
  272. existing.tenant_id == TENANT.bytes and existing.store_id == STORE.bytes
  273. and existing.can_fulfill == can_fulfill and existing.enabled,
  274. "已有 OMS 凭据权限与课程配置不一致",
  275. )
  276. finally:
  277. await engine.dispose()
  278. await oms.dispose()
  279. def configure_runtime(secret):
  280. path = ROOT / ".work" / "runtime.env"
  281. require(path.is_file(), "完整项目的 .work/runtime.env 不存在")
  282. existing = dotenv_values(path).get(TOKEN_ENV)
  283. require(
  284. existing is None or existing == secret["oms_platform_token"],
  285. "运行配置已存在不同的课程 OMS 凭据",
  286. )
  287. if existing is None:
  288. set_key(path, TOKEN_ENV, secret["oms_platform_token"], quote_mode="always")
  289. async def request(client, method, url, *, token=None, body=None, key=None, allow_missing=False):
  290. headers = {}
  291. if token:
  292. headers["Authorization"] = f"Bearer {token}"
  293. if key:
  294. headers["Idempotency-Key"] = key
  295. response = await client.request(method, url, headers=headers, json=body)
  296. if allow_missing and response.status_code == 404:
  297. return None
  298. response.raise_for_status()
  299. return response.json()
  300. async def login(client, manifest, actor, password):
  301. result = await request(client, "POST", manifest["api_url"] + "/openapi/v1/auth/sessions", body={
  302. "tenant_id": str(TENANT), "username": actor["username"], "password": password,
  303. })
  304. return result["access_token"], time.monotonic() + result["expires_in"] - 60
  305. def verify_snapshot(manifest, snapshot):
  306. expected = manifest["order"]
  307. require(
  308. snapshot["tenant_id"] == str(TENANT) and snapshot["store_id"] == str(STORE)
  309. and snapshot["subject_id"] == manifest["customer"]["principal_id"]
  310. and snapshot["order_id"] == expected["order_id"]
  311. and snapshot["item_id"] == "keyboard-01" and snapshot["product_type"] == "keyboard"
  312. and datetime.fromisoformat(snapshot["signed_at"]) == datetime.fromisoformat(
  313. expected["signed_at"]),
  314. "OMS 订单身份、商品或签收时间与课程记录不一致",
  315. )
  316. require(
  317. snapshot["source_version"] >= 1 and 0 <= snapshot["available_quantity"] <= 1,
  318. "OMS 订单版本或数量不符合课程订单约束",
  319. )
  320. async def synchronize(client, manifest, secret, token):
  321. snapshot = await request(
  322. client, "GET", manifest["oms_url"] + "/orders/" + manifest["order"]["order_id"],
  323. token=secret["oms_platform_token"],
  324. )
  325. verify_snapshot(manifest, snapshot)
  326. body = {name: snapshot[name] for name in (
  327. "store_id", "subject_id", "source_version", "item_id", "product_type",
  328. "available_quantity", "signed_at",
  329. )}
  330. body["source_system"] = "oms"
  331. # 每次可信观察使用新的幂等键,确保相同来源版本也更新 observed_at。
  332. await request(
  333. client, "PUT", manifest["api_url"] + "/internal/v1/orders/" + snapshot["order_id"],
  334. token=token, body=body, key=f"course-sync-{uuid4().hex}",
  335. )
  336. manifest["last_sync_at"] = utc_now()
  337. manifest["last_order_snapshot"] = snapshot
  338. return snapshot
  339. async def prepare(settings, args):
  340. async with asyncio.timeout(90):
  341. manifest, secret = await asyncio.to_thread(prepare_files, args)
  342. await bootstrap(settings, manifest, secret)
  343. await asyncio.to_thread(configure_runtime, secret)
  344. async with httpx.AsyncClient(timeout=10, trust_env=False) as client:
  345. order_url = manifest["oms_url"] + "/orders/" + manifest["order"]["order_id"]
  346. existing = await request(client, "GET", order_url,
  347. token=secret["oms_platform_token"], allow_missing=True)
  348. if existing is None:
  349. body = {name: value for name, value in manifest["order"].items()
  350. if name != "order_id"}
  351. body["subject_id"] = manifest["customer"]["principal_id"]
  352. await request(client, "PUT", order_url,
  353. token=secret["oms_fulfillment_token"], body=body)
  354. else:
  355. verify_snapshot(manifest, existing)
  356. service_token, _ = await login(
  357. client, manifest, secret["service"], secret["service"]["password"],
  358. )
  359. snapshot = await synchronize(client, manifest, secret, service_token)
  360. customer_token, _ = await login(
  361. client, manifest, manifest["customer"], secret["customer_password"],
  362. )
  363. order = await request(
  364. client, "GET", manifest["api_url"] + "/openapi/v1/orders/" + snapshot["order_id"],
  365. token=customer_token,
  366. )
  367. require(
  368. order["subject_id"] == manifest["customer"]["principal_id"]
  369. and order["source_system"] == "oms"
  370. and order["source_version"] == snapshot["source_version"]
  371. and order["available_quantity"] == snapshot["available_quantity"],
  372. "客户读取的订单与 OMS 当前订单不一致",
  373. )
  374. conversation = await request(
  375. client, "POST", manifest["api_url"] + "/openapi/v1/conversations",
  376. token=customer_token, body={"store_id": str(STORE)},
  377. key=f"course-conversation-{manifest['customer']['principal_id']}",
  378. )
  379. manifest["conversation_id"] = conversation["conversation_id"]
  380. unused = (
  381. conversation["sequence"] == 0 and manifest["existing_ticket_count"] == 0
  382. and snapshot["source_version"] == 1 and snapshot["available_quantity"] == 1
  383. )
  384. if manifest["status"] != "OMS_COMPLETED":
  385. manifest["status"] = "READY" if unused else "IN_USE"
  386. manifest["verification"] = {
  387. "verified_at": utc_now(), "customer_order_read": True,
  388. "conversation_sequence": conversation["sequence"],
  389. "generation_model_calls": 0,
  390. }
  391. await asyncio.to_thread(save_manifest, manifest, activate=True)
  392. return manifest
  393. async def sync_loop(args):
  394. manifest = await asyncio.to_thread(load_manifest, args.session)
  395. secret = await asyncio.to_thread(credentials)
  396. duration = args.duration_seconds
  397. deadline = time.monotonic() + duration
  398. maximum = max(1, math.ceil(duration / args.interval_seconds))
  399. token, expires = None, 0
  400. count = 0
  401. async with (
  402. asyncio.timeout(duration + 35),
  403. httpx.AsyncClient(timeout=10, trust_env=False) as client,
  404. ):
  405. for index in range(maximum):
  406. if index and time.monotonic() >= deadline:
  407. break
  408. # 锁等待计入本次同步的 30 秒期限,短时间重叠的课程操作允许等待完成。
  409. lock = FileLock(DATA / "course.lock", timeout=10, thread_local=False)
  410. async with asyncio.timeout(30):
  411. await asyncio.to_thread(lock.acquire)
  412. try:
  413. manifest = await asyncio.to_thread(load_manifest, manifest["session"])
  414. if token is None or time.monotonic() >= expires:
  415. token, expires = await login(
  416. client, manifest, secret["service"], secret["service"]["password"],
  417. )
  418. await synchronize(client, manifest, secret, token)
  419. await asyncio.to_thread(save_manifest, manifest)
  420. finally:
  421. await asyncio.to_thread(lock.release)
  422. count += 1
  423. print(json.dumps({
  424. "session": manifest["session"], "sync_count": count,
  425. "last_sync_at": manifest["last_sync_at"],
  426. "source_version": manifest["last_order_snapshot"]["source_version"],
  427. "available_quantity": manifest["last_order_snapshot"]["available_quantity"],
  428. }, ensure_ascii=False), flush=True)
  429. if index + 1 < maximum:
  430. remaining = deadline - time.monotonic()
  431. if remaining <= 0:
  432. break
  433. await asyncio.sleep(min(args.interval_seconds, remaining))
  434. return {"session": manifest["session"], "sync_count": count, "generation_model_calls": 0}
  435. async def complete(settings, args):
  436. async with asyncio.timeout(90):
  437. manifest = await asyncio.to_thread(load_manifest, args.session)
  438. secret = await asyncio.to_thread(credentials)
  439. engine = create_engine(settings)
  440. try:
  441. sessions = async_sessionmaker(engine, expire_on_commit=False)
  442. async with sessions() as session:
  443. query = select(ExternalOperation).where(
  444. ExternalOperation.tenant_id == TENANT.bytes,
  445. ExternalOperation.store_id == STORE.bytes,
  446. ExternalOperation.order_id == UUID(manifest["order"]["order_id"]).bytes,
  447. ExternalOperation.status.in_(("ACCEPTED", "COMPLETED")),
  448. )
  449. if args.operation_id:
  450. query = query.where(ExternalOperation.id == args.operation_id.bytes)
  451. operations = (await session.scalars(query)).all()
  452. require(
  453. len(operations) == 1,
  454. "需要唯一的本课程已受理履约操作,请检查审批与履约进度",
  455. )
  456. operation_id = str(UUID(bytes=operations[0].id))
  457. ticket_id = str(UUID(bytes=operations[0].ticket_id))
  458. finally:
  459. await engine.dispose()
  460. async with httpx.AsyncClient(timeout=10, trust_env=False) as client:
  461. url = manifest["oms_url"] + "/exchanges/" + operation_id
  462. existing = await request(client, "GET", url, token=secret["oms_fulfillment_token"])
  463. require(
  464. existing["order_id"] == manifest["order"]["order_id"]
  465. and existing["status"] in {"ACCEPTED", "COMPLETED"},
  466. "OMS 操作不属于本课程订单或当前状态不能完成",
  467. )
  468. result = await request(client, "POST", url + "/complete",
  469. token=secret["oms_fulfillment_token"])
  470. require(result["status"] == "COMPLETED", "OMS 未返回履约完成状态")
  471. manifest["completion"] = {**result, "ticket_id": ticket_id, "completed_at": utc_now()}
  472. manifest["status"] = "OMS_COMPLETED"
  473. service_token, _ = await login(
  474. client, manifest, secret["service"], secret["service"]["password"],
  475. )
  476. await synchronize(client, manifest, secret, service_token)
  477. await asyncio.to_thread(save_manifest, manifest)
  478. return {
  479. "session": manifest["session"], **manifest["completion"], "generation_model_calls": 0,
  480. }
  481. async def locked_command(settings, args):
  482. # 跨线程共享同一个锁状态;准备与完成的 90 秒期限包含最长 10 秒的锁等待。
  483. lock = FileLock(DATA / "course.lock", timeout=10, thread_local=False)
  484. async with asyncio.timeout(90):
  485. await asyncio.to_thread(lock.acquire)
  486. try:
  487. if args.command == "prepare":
  488. return await prepare(settings, args)
  489. return await complete(settings, args)
  490. finally:
  491. await asyncio.to_thread(lock.release)
  492. def main():
  493. parser = argparse.ArgumentParser(description="准备、同步和完成完整项目的课程演示订单")
  494. commands = parser.add_subparsers(dest="command", required=True)
  495. prepare_parser = commands.add_parser("prepare", help="创建本次课程客户、OMS 订单与空会话")
  496. prepare_parser.add_argument("--session", type=session_name)
  497. prepare_parser.add_argument("--api-url", type=validate_url, default="http://127.0.0.1:28000")
  498. prepare_parser.add_argument("--oms-url", type=validate_url, default="http://127.0.0.1:28100")
  499. sync_parser = commands.add_parser("sync", help="从 OMS 读取并同步当前课程订单")
  500. sync_parser.add_argument("--session", type=session_name)
  501. sync_parser.add_argument("--duration-seconds", type=int, default=0)
  502. sync_parser.add_argument("--interval-seconds", type=int, default=60)
  503. complete_parser = commands.add_parser("complete", help="使用仓储身份登记本课程履约完成")
  504. complete_parser.add_argument("--session", type=session_name)
  505. complete_parser.add_argument("--operation-id", type=UUID)
  506. args = parser.parse_args()
  507. settings = Settings()
  508. require(settings.environment in {"development", "test"}, "课程工具仅允许 development 或 test")
  509. require(Path.cwd().resolve() == ROOT, "请在完整项目根目录执行课程工具")
  510. DATA.mkdir(parents=True, exist_ok=True)
  511. if args.command == "sync":
  512. require(0 <= args.duration_seconds <= 43200, "同步时长须为 0 至 43200 秒")
  513. require(30 <= args.interval_seconds <= 240, "同步间隔须为 30 至 240 秒")
  514. result = asyncio.run(sync_loop(args))
  515. else:
  516. result = asyncio.run(locked_command(settings, args))
  517. print(json.dumps(result, ensure_ascii=False, indent=2))
  518. if __name__ == "__main__":
  519. main()