dbdirectory.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227
  1. from __future__ import annotations
  2. import asyncio
  3. import hashlib
  4. import json
  5. import math
  6. import re
  7. from datetime import UTC, datetime, timedelta
  8. from typing import Any
  9. from uuid import uuid4
  10. from pymongo import ASCENDING, DESCENDING, GEOSPHERE, ReturnDocument
  11. from pymongo.errors import DuplicateKeyError, OperationFailure
  12. from wbb import BOT_PROFILE_ID, control_db
  13. profilesdb = control_db.directory_profiles
  14. locationsdb = control_db.directory_locations
  15. membershipsdb = control_db.directory_memberships
  16. eventsdb = control_db.directory_events
  17. settingsdb = control_db.directory_settings
  18. geocode_cachedb = control_db.directory_geocode_cache
  19. geocode_jobsdb = control_db.directory_geocode_jobs
  20. APPLICATION_NONE = "none"
  21. APPLICATION_PENDING = "pending"
  22. APPLICATION_APPROVED = "approved"
  23. APPLICATION_REJECTED = "rejected"
  24. APPLICATION_REVOKED = "revoked"
  25. APPLICATION_STATUSES = {
  26. APPLICATION_NONE,
  27. APPLICATION_PENDING,
  28. APPLICATION_APPROVED,
  29. APPLICATION_REJECTED,
  30. APPLICATION_REVOKED,
  31. }
  32. PRESENCE_ONLINE = "online"
  33. PRESENCE_OFFLINE = "offline"
  34. DEFAULT_DIRECTORY_SETTINGS: dict[str, Any] = {
  35. "nominatim_enabled": True,
  36. "nominatim_endpoint": "https://nominatim.openstreetmap.org/reverse",
  37. "nominatim_contact": "",
  38. "nominatim_user_agent": "TelegramBotAdmin/1.0",
  39. "online_duration_hours": 24,
  40. "nearby_radius_km": 20,
  41. "nearby_radius_options_km": [5, 10, 20, 50],
  42. }
  43. _index_lock = asyncio.Lock()
  44. _indexes_ready = False
  45. class DirectoryDataError(ValueError):
  46. def __init__(self, code: str, message: str):
  47. super().__init__(message)
  48. self.code = code
  49. def utc_now() -> datetime:
  50. return datetime.now(UTC)
  51. def aware_utc(value: datetime | None) -> datetime | None:
  52. if value is None:
  53. return None
  54. if value.tzinfo is None:
  55. return value.replace(tzinfo=UTC)
  56. return value.astimezone(UTC)
  57. def display_name(
  58. first_name: str | None,
  59. last_name: str | None,
  60. username: str | None = None,
  61. user_id: int | None = None,
  62. ) -> str:
  63. name = " ".join(
  64. value.strip()
  65. for value in (first_name, last_name)
  66. if value and value.strip()
  67. )
  68. if name:
  69. return name
  70. if username:
  71. return f"@{username}"
  72. return f"用户 {user_id}" if user_id is not None else "未知用户"
  73. async def ensure_directory_indexes() -> None:
  74. global _indexes_ready
  75. if _indexes_ready:
  76. return
  77. async with _index_lock:
  78. if _indexes_ready:
  79. return
  80. await profilesdb.create_index([("user_id", ASCENDING)], unique=True)
  81. await profilesdb.create_index(
  82. [
  83. ("application_status", ASCENDING),
  84. ("listed", ASCENDING),
  85. ("updated_at", DESCENDING),
  86. ]
  87. )
  88. await profilesdb.create_index(
  89. [("presence_status", ASCENDING), ("online_until", ASCENDING)]
  90. )
  91. await locationsdb.create_index([("user_id", ASCENDING)], unique=True)
  92. await locationsdb.create_index([("point", GEOSPHERE)])
  93. await locationsdb.create_index([("updated_at", DESCENDING)])
  94. await membershipsdb.create_index(
  95. [
  96. ("bot_id", ASCENDING),
  97. ("chat_id", ASCENDING),
  98. ("user_id", ASCENDING),
  99. ],
  100. unique=True,
  101. )
  102. await membershipsdb.create_index(
  103. [("user_id", ASCENDING), ("active", ASCENDING), ("verified_at", DESCENDING)]
  104. )
  105. await eventsdb.create_index([("event_id", ASCENDING)], unique=True)
  106. await eventsdb.create_index(
  107. [("idempotency_key", ASCENDING)],
  108. unique=True,
  109. sparse=True,
  110. )
  111. await eventsdb.create_index([("created_at", DESCENDING)])
  112. await eventsdb.create_index([("user_id", ASCENDING), ("created_at", DESCENDING)])
  113. await settingsdb.create_index([("settings_id", ASCENDING)], unique=True)
  114. await geocode_cachedb.create_index([("coordinate_key", ASCENDING)], unique=True)
  115. await geocode_jobsdb.create_index([("user_id", ASCENDING)], unique=True)
  116. await geocode_jobsdb.create_index(
  117. [("status", ASCENDING), ("next_attempt_at", ASCENDING)]
  118. )
  119. _indexes_ready = True
  120. async def get_directory_settings() -> dict[str, Any]:
  121. await ensure_directory_indexes()
  122. stored = await settingsdb.find_one({"settings_id": "global"}) or {}
  123. return {
  124. **DEFAULT_DIRECTORY_SETTINGS,
  125. **{
  126. key: value
  127. for key, value in stored.items()
  128. if key not in {"_id", "settings_id"}
  129. },
  130. }
  131. async def set_directory_settings(values: dict[str, Any]) -> dict[str, Any]:
  132. await ensure_directory_indexes()
  133. current = await get_directory_settings()
  134. target = {**current, **values}
  135. endpoint = str(target.get("nominatim_endpoint") or "").strip()
  136. if not endpoint.startswith(("https://", "http://127.0.0.1", "http://localhost")):
  137. raise DirectoryDataError(
  138. "invalid_nominatim_endpoint",
  139. "Nominatim 地址必须使用 HTTPS;仅本机自建服务可以使用 HTTP。",
  140. )
  141. try:
  142. radius_options = sorted(
  143. {
  144. int(value)
  145. for value in target.get("nearby_radius_options_km", [])
  146. if int(value) > 0
  147. }
  148. )
  149. default_radius = int(target.get("nearby_radius_km") or 0)
  150. except (TypeError, ValueError) as exc:
  151. raise DirectoryDataError(
  152. "invalid_radius_options", "附近范围必须是正整数。"
  153. ) from exc
  154. if not radius_options:
  155. raise DirectoryDataError("invalid_radius_options", "附近范围选项不能为空。")
  156. if default_radius not in radius_options:
  157. raise DirectoryDataError(
  158. "invalid_default_radius", "默认附近范围必须属于可选范围。"
  159. )
  160. contact = str(target.get("nominatim_contact") or "").strip()
  161. user_agent = str(
  162. target.get("nominatim_user_agent") or "TelegramBotAdmin/1.0"
  163. ).strip()
  164. if "\n" in contact or "\r" in contact or "\n" in user_agent or "\r" in user_agent:
  165. raise DirectoryDataError(
  166. "invalid_nominatim_identity",
  167. "Nominatim 联系信息和 User-Agent 不能包含换行符。",
  168. )
  169. if contact and "@" not in contact and not contact.startswith(("https://", "http://")):
  170. raise DirectoryDataError(
  171. "invalid_nominatim_contact",
  172. "Nominatim 联系信息必须是邮箱或 URL。",
  173. )
  174. target.update(
  175. {
  176. "nominatim_enabled": bool(target.get("nominatim_enabled")),
  177. "nominatim_endpoint": endpoint,
  178. "nominatim_contact": contact,
  179. "nominatim_user_agent": user_agent,
  180. "online_duration_hours": 24,
  181. "nearby_radius_km": default_radius,
  182. "nearby_radius_options_km": radius_options,
  183. "updated_at": utc_now(),
  184. }
  185. )
  186. await settingsdb.update_one(
  187. {"settings_id": "global"},
  188. {"$set": target, "$setOnInsert": {"created_at": utc_now()}},
  189. upsert=True,
  190. )
  191. return await get_directory_settings()
  192. async def record_directory_event(
  193. *,
  194. event_type: str,
  195. user_id: int,
  196. actor_id: int | str | None,
  197. actor_name: str = "",
  198. source: str,
  199. reason: str = "",
  200. metadata: dict[str, Any] | None = None,
  201. idempotency_key: str | None = None,
  202. bot_id: str | None = None,
  203. chat_id: int | None = None,
  204. ) -> dict[str, Any] | None:
  205. await ensure_directory_indexes()
  206. document = {
  207. "event_id": uuid4().hex,
  208. "event_type": event_type,
  209. "user_id": int(user_id),
  210. "actor_id": actor_id,
  211. "actor_name": actor_name,
  212. "source": source,
  213. "reason": reason,
  214. "metadata": metadata or {},
  215. "bot_id": bot_id or str(BOT_PROFILE_ID),
  216. "chat_id": int(chat_id) if chat_id is not None else None,
  217. "created_at": utc_now(),
  218. }
  219. if idempotency_key:
  220. document["idempotency_key"] = idempotency_key
  221. try:
  222. await eventsdb.insert_one(document)
  223. except DuplicateKeyError:
  224. return None
  225. return document
  226. async def upsert_directory_identity(
  227. *,
  228. user_id: int,
  229. username: str | None,
  230. first_name: str | None,
  231. last_name: str | None,
  232. source: str = "telegram",
  233. ) -> dict[str, Any]:
  234. await ensure_directory_indexes()
  235. normalized_username = str(username or "").lstrip("@").strip() or None
  236. now = utc_now()
  237. previous = await profilesdb.find_one({"user_id": int(user_id)})
  238. identity = {
  239. "username": normalized_username,
  240. "first_name": str(first_name or "").strip() or None,
  241. "last_name": str(last_name or "").strip() or None,
  242. "display_name": display_name(
  243. first_name,
  244. last_name,
  245. normalized_username,
  246. int(user_id),
  247. ),
  248. }
  249. updates: dict[str, Any] = {**identity, "updated_at": now}
  250. username_removed = bool(previous and previous.get("username") and not normalized_username)
  251. if username_removed and previous.get("application_status") == APPLICATION_APPROVED:
  252. updates.update(
  253. {
  254. "listed": False,
  255. "presence_status": PRESENCE_OFFLINE,
  256. "online_until": None,
  257. }
  258. )
  259. await profilesdb.update_one(
  260. {"user_id": int(user_id)},
  261. {
  262. "$set": updates,
  263. "$setOnInsert": {
  264. "application_status": APPLICATION_NONE,
  265. "listed": False,
  266. "presence_status": PRESENCE_OFFLINE,
  267. "created_at": now,
  268. },
  269. },
  270. upsert=True,
  271. )
  272. if username_removed:
  273. await record_directory_event(
  274. event_type="teacher_username_removed",
  275. user_id=int(user_id),
  276. actor_id=int(user_id),
  277. actor_name=identity["display_name"],
  278. source=source,
  279. reason="老师移除了 Telegram 用户名,系统自动下榜并下线。",
  280. )
  281. return await get_directory_profile(int(user_id)) or {}
  282. async def get_directory_profile(user_id: int) -> dict[str, Any] | None:
  283. await ensure_directory_indexes()
  284. return await profilesdb.find_one({"user_id": int(user_id)})
  285. async def get_directory_location(user_id: int) -> dict[str, Any] | None:
  286. await ensure_directory_indexes()
  287. return await locationsdb.find_one({"user_id": int(user_id)})
  288. def validate_coordinates(longitude: float, latitude: float) -> tuple[float, float]:
  289. try:
  290. lon = float(longitude)
  291. lat = float(latitude)
  292. except (TypeError, ValueError) as exc:
  293. raise DirectoryDataError("invalid_coordinates", "经纬度必须是数字。") from exc
  294. if not math.isfinite(lon) or not -180 <= lon <= 180:
  295. raise DirectoryDataError("invalid_longitude", "经度必须在 -180 到 180 之间。")
  296. if not math.isfinite(lat) or not -90 <= lat <= 90:
  297. raise DirectoryDataError("invalid_latitude", "纬度必须在 -90 到 90 之间。")
  298. return lon, lat
  299. def coordinate_key(longitude: float, latitude: float) -> str:
  300. raw = json.dumps(
  301. [float(longitude), float(latitude)],
  302. ensure_ascii=True,
  303. separators=(",", ":"),
  304. )
  305. return hashlib.sha256(raw.encode("ascii")).hexdigest()
  306. async def save_directory_location(
  307. *,
  308. user_id: int,
  309. longitude: float,
  310. latitude: float,
  311. source: str,
  312. actor_id: int | str,
  313. actor_name: str,
  314. geocode_consent: bool | None = None,
  315. bot_id: str | None = None,
  316. ) -> dict[str, Any]:
  317. await ensure_directory_indexes()
  318. lon, lat = validate_coordinates(longitude, latitude)
  319. previous = await get_directory_location(int(user_id))
  320. consent = (
  321. geocode_consent
  322. if geocode_consent is not None
  323. else previous.get("geocode_consent") if previous else None
  324. )
  325. key = coordinate_key(lon, lat)
  326. now = utc_now()
  327. status = "pending" if consent is True else "disabled" if consent is False else "consent_required"
  328. await locationsdb.update_one(
  329. {"user_id": int(user_id)},
  330. {
  331. "$set": {
  332. "point": {"type": "Point", "coordinates": [lon, lat]},
  333. "longitude": lon,
  334. "latitude": lat,
  335. "coordinate_key": key,
  336. "geocode_consent": consent,
  337. "geocode_status": status,
  338. "region": None,
  339. "source": source,
  340. "source_bot_id": bot_id or str(BOT_PROFILE_ID),
  341. "updated_at": now,
  342. },
  343. "$setOnInsert": {"created_at": now},
  344. },
  345. upsert=True,
  346. )
  347. await profilesdb.update_one(
  348. {"user_id": int(user_id)},
  349. {
  350. "$set": {"location_updated_at": now, "updated_at": now},
  351. "$setOnInsert": {
  352. "application_status": APPLICATION_NONE,
  353. "listed": False,
  354. "presence_status": PRESENCE_OFFLINE,
  355. "created_at": now,
  356. },
  357. },
  358. upsert=True,
  359. )
  360. await record_directory_event(
  361. event_type="location_updated",
  362. user_id=int(user_id),
  363. actor_id=actor_id,
  364. actor_name=actor_name,
  365. source=source,
  366. reason="更新位置",
  367. metadata={"geocode_consent": consent},
  368. bot_id=bot_id,
  369. )
  370. if consent is True:
  371. await enqueue_geocode_job(int(user_id))
  372. return await get_directory_location(int(user_id)) or {}
  373. async def set_geocode_consent(
  374. *,
  375. user_id: int,
  376. consent: bool,
  377. source: str,
  378. actor_id: int | str,
  379. actor_name: str,
  380. ) -> dict[str, Any]:
  381. location = await get_directory_location(int(user_id))
  382. if not location:
  383. raise DirectoryDataError("location_required", "请先更新位置。")
  384. updates: dict[str, Any] = {
  385. "geocode_consent": bool(consent),
  386. "geocode_status": "pending" if consent else "disabled",
  387. "updated_at": utc_now(),
  388. }
  389. if not consent:
  390. updates["region"] = None
  391. await geocode_jobsdb.delete_one({"user_id": int(user_id)})
  392. await locationsdb.update_one({"user_id": int(user_id)}, {"$set": updates})
  393. await record_directory_event(
  394. event_type="geocode_consent_updated",
  395. user_id=int(user_id),
  396. actor_id=actor_id,
  397. actor_name=actor_name,
  398. source=source,
  399. reason="同意区域解析" if consent else "关闭区域解析",
  400. metadata={"geocode_consent": bool(consent)},
  401. )
  402. if consent:
  403. await enqueue_geocode_job(int(user_id))
  404. return await get_directory_location(int(user_id)) or {}
  405. async def clear_directory_location(
  406. *,
  407. user_id: int,
  408. actor_id: int | str,
  409. actor_name: str,
  410. source: str,
  411. reason: str,
  412. ) -> bool:
  413. await ensure_directory_indexes()
  414. result = await locationsdb.delete_one({"user_id": int(user_id)})
  415. await geocode_jobsdb.delete_one({"user_id": int(user_id)})
  416. await profilesdb.update_one(
  417. {"user_id": int(user_id)},
  418. {
  419. "$set": {
  420. "listed": False,
  421. "presence_status": PRESENCE_OFFLINE,
  422. "online_until": None,
  423. "location_updated_at": None,
  424. "updated_at": utc_now(),
  425. }
  426. },
  427. )
  428. if result.deleted_count:
  429. await record_directory_event(
  430. event_type="location_cleared",
  431. user_id=int(user_id),
  432. actor_id=actor_id,
  433. actor_name=actor_name,
  434. source=source,
  435. reason=reason,
  436. )
  437. return bool(result.deleted_count)
  438. async def submit_teacher_application(
  439. *,
  440. user_id: int,
  441. source: str,
  442. bot_id: str,
  443. ) -> dict[str, Any]:
  444. await ensure_directory_indexes()
  445. profile = await get_directory_profile(int(user_id))
  446. location = await get_directory_location(int(user_id))
  447. if not profile or not profile.get("username"):
  448. raise DirectoryDataError(
  449. "username_required", "申请老师前必须先设置 Telegram 用户名。"
  450. )
  451. if not location:
  452. raise DirectoryDataError("location_required", "申请老师前必须先更新位置。")
  453. if profile.get("application_status") == APPLICATION_APPROVED:
  454. raise DirectoryDataError("already_teacher", "你已经是老师,无需重复申请。")
  455. if profile.get("application_status") == APPLICATION_REVOKED:
  456. raise DirectoryDataError(
  457. "teacher_revoked", "老师资格已被撤销,只能由管理员重新批准。"
  458. )
  459. if profile.get("application_status") == APPLICATION_PENDING:
  460. return profile
  461. now = utc_now()
  462. await profilesdb.update_one(
  463. {"user_id": int(user_id)},
  464. {
  465. "$set": {
  466. "application_status": APPLICATION_PENDING,
  467. "application_reason": "",
  468. "application_source_bot_id": str(bot_id),
  469. "applied_at": now,
  470. "decided_at": None,
  471. "decided_by": None,
  472. "updated_at": now,
  473. }
  474. },
  475. )
  476. await record_directory_event(
  477. event_type="teacher_application_submitted",
  478. user_id=int(user_id),
  479. actor_id=int(user_id),
  480. actor_name=str(profile.get("display_name") or user_id),
  481. source=source,
  482. reason="申请成为老师",
  483. bot_id=bot_id,
  484. )
  485. return await get_directory_profile(int(user_id)) or {}
  486. async def decide_teacher_application(
  487. *,
  488. user_id: int,
  489. action: str,
  490. actor_id: int | str,
  491. actor_name: str,
  492. source: str,
  493. reason: str = "",
  494. authorization_chat_id: int | None = None,
  495. ) -> dict[str, Any]:
  496. await ensure_directory_indexes()
  497. profile = await get_directory_profile(int(user_id))
  498. if not profile:
  499. raise DirectoryDataError("profile_not_found", "未找到该成员资料。")
  500. action_status = {
  501. "approve": APPLICATION_APPROVED,
  502. "reject": APPLICATION_REJECTED,
  503. "revoke": APPLICATION_REVOKED,
  504. }
  505. if action not in action_status:
  506. raise DirectoryDataError("invalid_decision", "不支持的审批操作。")
  507. if action in {"reject", "revoke"} and not reason.strip():
  508. raise DirectoryDataError("reason_required", "拒绝或撤销时必须填写原因。")
  509. if action == "approve":
  510. if not profile.get("username"):
  511. raise DirectoryDataError(
  512. "username_required", "该成员没有 Telegram 用户名,无法批准。"
  513. )
  514. if not await get_directory_location(int(user_id)):
  515. raise DirectoryDataError("location_required", "该成员尚未更新位置。")
  516. now = utc_now()
  517. updates: dict[str, Any] = {
  518. "application_status": action_status[action],
  519. "application_reason": reason.strip(),
  520. "decided_at": now,
  521. "decided_by": actor_id,
  522. "decided_by_name": actor_name,
  523. "authorization_chat_id": authorization_chat_id,
  524. "updated_at": now,
  525. }
  526. if action != "approve":
  527. updates.update(
  528. {
  529. "listed": False,
  530. "presence_status": PRESENCE_OFFLINE,
  531. "online_until": None,
  532. }
  533. )
  534. await profilesdb.update_one({"user_id": int(user_id)}, {"$set": updates})
  535. await record_directory_event(
  536. event_type=f"teacher_application_{action}",
  537. user_id=int(user_id),
  538. actor_id=actor_id,
  539. actor_name=actor_name,
  540. source=source,
  541. reason=reason,
  542. metadata={"authorization_chat_id": authorization_chat_id},
  543. chat_id=authorization_chat_id,
  544. )
  545. return await get_directory_profile(int(user_id)) or {}
  546. async def set_teacher_state(
  547. *,
  548. user_id: int,
  549. action: str,
  550. actor_id: int | str,
  551. actor_name: str,
  552. source: str,
  553. reason: str = "",
  554. idempotency_key: str | None = None,
  555. chat_id: int | None = None,
  556. ) -> tuple[dict[str, Any], bool]:
  557. await ensure_directory_indexes()
  558. profile = await get_directory_profile(int(user_id))
  559. if not profile or profile.get("application_status") != APPLICATION_APPROVED:
  560. raise DirectoryDataError("teacher_required", "该成员尚未取得老师资格。")
  561. location = await get_directory_location(int(user_id))
  562. if action in {"list", "online"}:
  563. if not profile.get("username"):
  564. raise DirectoryDataError("username_required", "请先设置 Telegram 用户名。")
  565. if not location:
  566. raise DirectoryDataError("location_required", "请先更新位置。")
  567. if action == "online" and not profile.get("listed"):
  568. raise DirectoryDataError("listing_required", "请先上榜,再切换为上线状态。")
  569. if action not in {"list", "unlist", "online", "offline"}:
  570. raise DirectoryDataError("invalid_teacher_action", "不支持的老师状态操作。")
  571. event = await record_directory_event(
  572. event_type=f"teacher_{action}",
  573. user_id=int(user_id),
  574. actor_id=actor_id,
  575. actor_name=actor_name,
  576. source=source,
  577. reason=reason or f"老师{action}",
  578. idempotency_key=idempotency_key,
  579. chat_id=chat_id,
  580. )
  581. applied = event is not None
  582. now = utc_now()
  583. updates: dict[str, Any] = {"updated_at": now}
  584. if action == "list":
  585. updates["listed"] = True
  586. updates["listed_at"] = now
  587. elif action == "unlist":
  588. updates.update(
  589. {
  590. "listed": False,
  591. "presence_status": PRESENCE_OFFLINE,
  592. "online_until": None,
  593. }
  594. )
  595. elif action == "online":
  596. updates.update(
  597. {
  598. "presence_status": PRESENCE_ONLINE,
  599. "online_until": now + timedelta(hours=24),
  600. "last_online_at": now,
  601. }
  602. )
  603. else:
  604. updates.update(
  605. {"presence_status": PRESENCE_OFFLINE, "online_until": None}
  606. )
  607. await profilesdb.update_one({"user_id": int(user_id)}, {"$set": updates})
  608. return await get_directory_profile(int(user_id)) or {}, applied
  609. async def expire_teacher_presence() -> int:
  610. await ensure_directory_indexes()
  611. now = utc_now()
  612. expired = 0
  613. while True:
  614. item = await profilesdb.find_one_and_update(
  615. {
  616. "presence_status": PRESENCE_ONLINE,
  617. "online_until": {"$lte": now},
  618. },
  619. {
  620. "$set": {
  621. "presence_status": PRESENCE_OFFLINE,
  622. "online_until": None,
  623. "updated_at": now,
  624. }
  625. },
  626. sort=[("online_until", ASCENDING)],
  627. return_document=ReturnDocument.BEFORE,
  628. )
  629. if not item:
  630. break
  631. expired += 1
  632. previous_until = aware_utc(item.get("online_until"))
  633. await record_directory_event(
  634. event_type="teacher_auto_offline",
  635. user_id=int(item["user_id"]),
  636. actor_id="system",
  637. actor_name="系统",
  638. source="scheduler",
  639. reason="上线状态已超过 24 小时。",
  640. idempotency_key=(
  641. f"teacher-auto-offline:{item['user_id']}:"
  642. f"{previous_until.isoformat() if previous_until else 'unknown'}"
  643. ),
  644. )
  645. return expired
  646. def teacher_is_online(profile: dict[str, Any], *, now: datetime | None = None) -> bool:
  647. current = now or utc_now()
  648. until = aware_utc(profile.get("online_until"))
  649. return bool(
  650. profile.get("presence_status") == PRESENCE_ONLINE
  651. and until
  652. and until > current
  653. )
  654. def _haversine_meters(
  655. longitude_a: float,
  656. latitude_a: float,
  657. longitude_b: float,
  658. latitude_b: float,
  659. ) -> float:
  660. radius = 6_371_008.8
  661. lon_a, lat_a, lon_b, lat_b = map(
  662. math.radians,
  663. (longitude_a, latitude_a, longitude_b, latitude_b),
  664. )
  665. delta_lon = lon_b - lon_a
  666. delta_lat = lat_b - lat_a
  667. value = (
  668. math.sin(delta_lat / 2) ** 2
  669. + math.cos(lat_a) * math.cos(lat_b) * math.sin(delta_lon / 2) ** 2
  670. )
  671. return 2 * radius * math.asin(math.sqrt(value))
  672. async def _list_teachers_fallback(
  673. *,
  674. longitude: float,
  675. latitude: float,
  676. max_distance_meters: float | None,
  677. query: str,
  678. page: int,
  679. page_size: int,
  680. ) -> tuple[list[dict[str, Any]], int]:
  681. profile_filter: dict[str, Any] = {
  682. "application_status": APPLICATION_APPROVED,
  683. "listed": True,
  684. "username": {"$nin": [None, ""]},
  685. }
  686. if query:
  687. pattern = re.compile(re.escape(query.lstrip("@")), re.IGNORECASE)
  688. profile_filter["$or"] = [
  689. {"username": pattern},
  690. {"display_name": pattern},
  691. {"user_id": int(query)} if query.isdigit() else {"user_id": -1},
  692. ]
  693. profiles = {
  694. int(item["user_id"]): item
  695. async for item in profilesdb.find(profile_filter)
  696. }
  697. values: list[dict[str, Any]] = []
  698. if profiles:
  699. async for location in locationsdb.find(
  700. {"user_id": {"$in": list(profiles)}}
  701. ):
  702. point = location.get("point", {}).get("coordinates") or []
  703. if len(point) != 2:
  704. continue
  705. distance = _haversine_meters(
  706. longitude,
  707. latitude,
  708. float(point[0]),
  709. float(point[1]),
  710. )
  711. if max_distance_meters is not None and distance > max_distance_meters:
  712. continue
  713. values.append(
  714. {
  715. **profiles[int(location["user_id"])],
  716. "distance_meters": distance,
  717. "region": location.get("region"),
  718. "location_updated_at": location.get("updated_at"),
  719. }
  720. )
  721. values.sort(key=lambda item: (item["distance_meters"], int(item["user_id"])))
  722. total = len(values)
  723. start = (page - 1) * page_size
  724. return values[start : start + page_size], total
  725. async def list_directory_teachers(
  726. *,
  727. longitude: float,
  728. latitude: float,
  729. max_distance_meters: float | None = None,
  730. query: str = "",
  731. page: int = 1,
  732. page_size: int = 10,
  733. ) -> tuple[list[dict[str, Any]], int]:
  734. await ensure_directory_indexes()
  735. lon, lat = validate_coordinates(longitude, latitude)
  736. profile_match: dict[str, Any] = {
  737. "profile.application_status": APPLICATION_APPROVED,
  738. "profile.listed": True,
  739. "profile.username": {"$nin": [None, ""]},
  740. }
  741. if query:
  742. pattern = re.compile(re.escape(query.lstrip("@")), re.IGNORECASE)
  743. profile_match["$or"] = [
  744. {"profile.username": pattern},
  745. {"profile.display_name": pattern},
  746. {"profile.user_id": int(query)} if query.isdigit() else {"profile.user_id": -1},
  747. ]
  748. geo_near: dict[str, Any] = {
  749. "near": {"type": "Point", "coordinates": [lon, lat]},
  750. "distanceField": "distance_meters",
  751. "spherical": True,
  752. "key": "point",
  753. }
  754. if max_distance_meters is not None:
  755. geo_near["maxDistance"] = float(max_distance_meters)
  756. pipeline = [
  757. {"$geoNear": geo_near},
  758. {
  759. "$lookup": {
  760. "from": profilesdb.name,
  761. "localField": "user_id",
  762. "foreignField": "user_id",
  763. "as": "profile",
  764. }
  765. },
  766. {"$unwind": "$profile"},
  767. {"$match": profile_match},
  768. {"$sort": {"distance_meters": 1, "user_id": 1}},
  769. {
  770. "$facet": {
  771. "items": [
  772. {"$skip": (max(1, page) - 1) * max(1, page_size)},
  773. {"$limit": max(1, page_size)},
  774. ],
  775. "count": [{"$count": "total"}],
  776. }
  777. },
  778. ]
  779. try:
  780. results = await locationsdb.aggregate(pipeline).to_list(length=1)
  781. result = results[0] if results else {"items": [], "count": []}
  782. items = [
  783. {
  784. **item["profile"],
  785. "distance_meters": item["distance_meters"],
  786. "region": item.get("region"),
  787. "location_updated_at": item.get("updated_at"),
  788. }
  789. for item in result.get("items", [])
  790. ]
  791. count = result.get("count") or []
  792. return items, int(count[0]["total"]) if count else 0
  793. except (NotImplementedError, OperationFailure):
  794. return await _list_teachers_fallback(
  795. longitude=lon,
  796. latitude=lat,
  797. max_distance_meters=max_distance_meters,
  798. query=query,
  799. page=page,
  800. page_size=page_size,
  801. )
  802. async def list_teacher_applications(
  803. *,
  804. status: str = "",
  805. query: str = "",
  806. page: int = 1,
  807. page_size: int = 20,
  808. ) -> tuple[list[dict[str, Any]], int]:
  809. await ensure_directory_indexes()
  810. filters: dict[str, Any] = {}
  811. if status:
  812. if status not in APPLICATION_STATUSES:
  813. raise DirectoryDataError("invalid_application_status", "申请状态无效。")
  814. filters["application_status"] = status
  815. else:
  816. filters["application_status"] = {"$ne": APPLICATION_NONE}
  817. if query:
  818. pattern = re.compile(re.escape(query.lstrip("@")), re.IGNORECASE)
  819. filters["$or"] = [
  820. {"username": pattern},
  821. {"display_name": pattern},
  822. {"user_id": int(query)} if query.isdigit() else {"user_id": -1},
  823. ]
  824. total = await profilesdb.count_documents(filters)
  825. items = await (
  826. profilesdb.find(filters)
  827. .sort([("applied_at", DESCENDING), ("updated_at", DESCENDING)])
  828. .skip((page - 1) * page_size)
  829. .limit(page_size)
  830. .to_list(length=page_size)
  831. )
  832. location_map = {
  833. int(item["user_id"]): item
  834. async for item in locationsdb.find(
  835. {"user_id": {"$in": [int(item["user_id"]) for item in items]}}
  836. )
  837. }
  838. for item in items:
  839. location = location_map.get(int(item["user_id"]))
  840. item["location"] = (
  841. {
  842. "region": location.get("region"),
  843. "geocode_status": location.get("geocode_status"),
  844. "updated_at": location.get("updated_at"),
  845. }
  846. if location
  847. else None
  848. )
  849. item["online"] = teacher_is_online(item)
  850. return items, total
  851. async def list_directory_profiles(
  852. *,
  853. query: str = "",
  854. application_status: str = "",
  855. listed: bool | None = None,
  856. page: int = 1,
  857. page_size: int = 20,
  858. ) -> tuple[list[dict[str, Any]], int]:
  859. filters: dict[str, Any] = {}
  860. if application_status:
  861. filters["application_status"] = application_status
  862. if listed is not None:
  863. filters["listed"] = listed
  864. if query:
  865. pattern = re.compile(re.escape(query.lstrip("@")), re.IGNORECASE)
  866. filters["$or"] = [
  867. {"username": pattern},
  868. {"display_name": pattern},
  869. {"user_id": int(query)} if query.isdigit() else {"user_id": -1},
  870. ]
  871. total = await profilesdb.count_documents(filters)
  872. items = await (
  873. profilesdb.find(filters)
  874. .sort([("updated_at", DESCENDING)])
  875. .skip((page - 1) * page_size)
  876. .limit(page_size)
  877. .to_list(length=page_size)
  878. )
  879. locations = {
  880. int(item["user_id"]): item
  881. async for item in locationsdb.find(
  882. {"user_id": {"$in": [int(item["user_id"]) for item in items]}}
  883. )
  884. }
  885. for item in items:
  886. location = locations.get(int(item["user_id"]))
  887. item["location"] = (
  888. {
  889. "region": location.get("region"),
  890. "geocode_status": location.get("geocode_status"),
  891. "updated_at": location.get("updated_at"),
  892. }
  893. if location
  894. else None
  895. )
  896. item["online"] = teacher_is_online(item)
  897. return items, total
  898. async def list_directory_locations(
  899. *,
  900. query: str = "",
  901. page: int = 1,
  902. page_size: int = 20,
  903. ) -> tuple[list[dict[str, Any]], int]:
  904. await ensure_directory_indexes()
  905. filters: dict[str, Any] = {}
  906. if query:
  907. pattern = re.compile(re.escape(query.lstrip("@")), re.IGNORECASE)
  908. profile_filters = {
  909. "$or": [
  910. {"username": pattern},
  911. {"display_name": pattern},
  912. {"user_id": int(query)} if query.isdigit() else {"user_id": -1},
  913. ]
  914. }
  915. user_ids = [
  916. int(item["user_id"])
  917. async for item in profilesdb.find(profile_filters, {"user_id": 1})
  918. ]
  919. filters["user_id"] = {"$in": user_ids}
  920. total = await locationsdb.count_documents(filters)
  921. items = await (
  922. locationsdb.find(filters)
  923. .sort("updated_at", DESCENDING)
  924. .skip((page - 1) * page_size)
  925. .limit(page_size)
  926. .to_list(length=page_size)
  927. )
  928. profiles = {
  929. int(item["user_id"]): item
  930. async for item in profilesdb.find(
  931. {"user_id": {"$in": [int(item["user_id"]) for item in items]}}
  932. )
  933. }
  934. for item in items:
  935. item["profile"] = profiles.get(int(item["user_id"]), {})
  936. return items, total
  937. async def list_directory_events(
  938. *,
  939. query: str = "",
  940. event_type: str = "",
  941. page: int = 1,
  942. page_size: int = 20,
  943. ) -> tuple[list[dict[str, Any]], int]:
  944. filters: dict[str, Any] = {}
  945. if event_type:
  946. filters["event_type"] = event_type
  947. if query:
  948. pattern = re.compile(re.escape(query), re.IGNORECASE)
  949. filters["$or"] = [
  950. {"actor_name": pattern},
  951. {"reason": pattern},
  952. {"user_id": int(query)} if query.isdigit() else {"user_id": -1},
  953. ]
  954. total = await eventsdb.count_documents(filters)
  955. items = await (
  956. eventsdb.find(filters)
  957. .sort("created_at", DESCENDING)
  958. .skip((page - 1) * page_size)
  959. .limit(page_size)
  960. .to_list(length=page_size)
  961. )
  962. return items, total
  963. async def upsert_directory_membership(
  964. *,
  965. bot_id: str,
  966. chat_id: int,
  967. user_id: int,
  968. status: str,
  969. active: bool,
  970. chat_title: str = "",
  971. username: str | None = None,
  972. display_name_value: str = "",
  973. verified: bool = False,
  974. ) -> dict[str, Any]:
  975. await ensure_directory_indexes()
  976. now = utc_now()
  977. values: dict[str, Any] = {
  978. "status": status,
  979. "active": bool(active),
  980. "username": username,
  981. "display_name": display_name_value,
  982. "observed_at": now,
  983. "updated_at": now,
  984. }
  985. if chat_title:
  986. values["chat_title"] = chat_title
  987. if verified:
  988. values["verified_at"] = now
  989. await membershipsdb.update_one(
  990. {
  991. "bot_id": str(bot_id),
  992. "chat_id": int(chat_id),
  993. "user_id": int(user_id),
  994. },
  995. {"$set": values, "$setOnInsert": {"created_at": now}},
  996. upsert=True,
  997. )
  998. return await membershipsdb.find_one(
  999. {
  1000. "bot_id": str(bot_id),
  1001. "chat_id": int(chat_id),
  1002. "user_id": int(user_id),
  1003. }
  1004. ) or {}
  1005. async def list_membership_candidates(user_id: int) -> list[dict[str, Any]]:
  1006. await ensure_directory_indexes()
  1007. return await membershipsdb.find(
  1008. {"user_id": int(user_id), "active": True}
  1009. ).sort("verified_at", DESCENDING).to_list(length=200)
  1010. async def enqueue_geocode_job(user_id: int) -> dict[str, Any] | None:
  1011. await ensure_directory_indexes()
  1012. location = await get_directory_location(int(user_id))
  1013. if not location or location.get("geocode_consent") is not True:
  1014. return None
  1015. key = str(location["coordinate_key"])
  1016. cached = await geocode_cachedb.find_one({"coordinate_key": key})
  1017. if cached:
  1018. await locationsdb.update_one(
  1019. {"user_id": int(user_id), "coordinate_key": key},
  1020. {
  1021. "$set": {
  1022. "region": cached.get("region"),
  1023. "geocode_status": "completed",
  1024. "geocoded_at": cached.get("created_at"),
  1025. "updated_at": utc_now(),
  1026. }
  1027. },
  1028. )
  1029. return None
  1030. now = utc_now()
  1031. await geocode_jobsdb.update_one(
  1032. {"user_id": int(user_id)},
  1033. {
  1034. "$set": {
  1035. "coordinate_key": key,
  1036. "longitude": float(location["longitude"]),
  1037. "latitude": float(location["latitude"]),
  1038. "status": "pending",
  1039. "attempts": 0,
  1040. "next_attempt_at": now,
  1041. "updated_at": now,
  1042. },
  1043. "$setOnInsert": {"created_at": now},
  1044. },
  1045. upsert=True,
  1046. )
  1047. return await geocode_jobsdb.find_one({"user_id": int(user_id)})
  1048. async def claim_geocode_job() -> dict[str, Any] | None:
  1049. await ensure_directory_indexes()
  1050. now = utc_now()
  1051. return await geocode_jobsdb.find_one_and_update(
  1052. {
  1053. "status": "pending",
  1054. "$or": [
  1055. {"next_attempt_at": {"$lte": now}},
  1056. {"next_attempt_at": {"$exists": False}},
  1057. ],
  1058. },
  1059. {
  1060. "$set": {
  1061. "status": "processing",
  1062. "claimed_at": now,
  1063. "updated_at": now,
  1064. },
  1065. "$inc": {"attempts": 1},
  1066. },
  1067. sort=[("created_at", ASCENDING)],
  1068. return_document=ReturnDocument.AFTER,
  1069. )
  1070. async def complete_geocode_job(
  1071. *,
  1072. job: dict[str, Any],
  1073. region: dict[str, Any],
  1074. ) -> None:
  1075. now = utc_now()
  1076. key = str(job["coordinate_key"])
  1077. await geocode_cachedb.update_one(
  1078. {"coordinate_key": key},
  1079. {
  1080. "$setOnInsert": {
  1081. "coordinate_key": key,
  1082. "region": region,
  1083. "provider": "nominatim",
  1084. "created_at": now,
  1085. }
  1086. },
  1087. upsert=True,
  1088. )
  1089. await locationsdb.update_one(
  1090. {
  1091. "user_id": int(job["user_id"]),
  1092. "coordinate_key": key,
  1093. "geocode_consent": True,
  1094. },
  1095. {
  1096. "$set": {
  1097. "region": region,
  1098. "geocode_status": "completed",
  1099. "geocoded_at": now,
  1100. "updated_at": now,
  1101. }
  1102. },
  1103. )
  1104. await geocode_jobsdb.delete_one(
  1105. {"user_id": int(job["user_id"]), "coordinate_key": key}
  1106. )
  1107. async def fail_geocode_job(
  1108. *,
  1109. job: dict[str, Any],
  1110. error: str,
  1111. retry_after_seconds: int = 60,
  1112. ) -> None:
  1113. attempts = int(job.get("attempts") or 1)
  1114. key_filter = {
  1115. "user_id": int(job["user_id"]),
  1116. "coordinate_key": str(job["coordinate_key"]),
  1117. }
  1118. if attempts >= 3:
  1119. await geocode_jobsdb.update_one(
  1120. key_filter,
  1121. {
  1122. "$set": {
  1123. "status": "failed",
  1124. "error": error[:500],
  1125. "updated_at": utc_now(),
  1126. }
  1127. },
  1128. )
  1129. await locationsdb.update_one(
  1130. key_filter,
  1131. {
  1132. "$set": {
  1133. "geocode_status": "failed",
  1134. "geocode_error": error[:500],
  1135. "updated_at": utc_now(),
  1136. }
  1137. },
  1138. )
  1139. return
  1140. await geocode_jobsdb.update_one(
  1141. key_filter,
  1142. {
  1143. "$set": {
  1144. "status": "pending",
  1145. "error": error[:500],
  1146. "next_attempt_at": utc_now()
  1147. + timedelta(seconds=max(1, retry_after_seconds)),
  1148. "updated_at": utc_now(),
  1149. }
  1150. },
  1151. )