technician_services.py 76 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855
  1. from __future__ import annotations
  2. import asyncio
  3. from contextlib import suppress
  4. from datetime import UTC, datetime, timedelta
  5. from html import escape
  6. from io import BytesIO
  7. from typing import Any
  8. from urllib.parse import quote
  9. import qrcode
  10. from pyrogram import StopPropagation, filters
  11. from pyrogram.enums import ParseMode
  12. from pyrogram.types import (
  13. CallbackQuery,
  14. InlineKeyboardButton,
  15. InlineKeyboardMarkup,
  16. KeyboardButton,
  17. Message,
  18. ReplyKeyboardMarkup,
  19. WebAppInfo,
  20. )
  21. import wbb
  22. from wbb import BOT_USERNAME, app, log
  23. from wbb.core.reply_keyboard import remove_reply_keyboard
  24. from wbb.services.technician_reviews import ensure_technician_review_topic
  25. from wbb.utils.dbservice import (
  26. ServiceDataError,
  27. begin_review_draft,
  28. cancel_service_order,
  29. claim_review_qr,
  30. confirm_service_quote,
  31. create_service_order,
  32. create_service_report,
  33. delete_review_draft,
  34. dispute_service_order,
  35. get_active_review_template,
  36. get_order_for_actor,
  37. get_technician_review_summary,
  38. get_technician_review_topic,
  39. get_technician_service_profile,
  40. instantiate_package_template,
  41. issue_review_qr,
  42. list_actor_service_orders,
  43. list_available_technicians,
  44. list_leaderboard,
  45. list_package_templates,
  46. list_public_reviews,
  47. normalize_quote,
  48. observe_customer,
  49. preview_review_qr,
  50. publish_technician_profile,
  51. record_service_funnel_event,
  52. redact_expired_addresses,
  53. reject_service_order,
  54. require_customer_allowed,
  55. save_review_draft_progress,
  56. set_technician_customer_block,
  57. start_service_order,
  58. submit_review,
  59. submit_service_quote,
  60. )
  61. __MODULE__ = "技师服务"
  62. __HELP__ = """/find_technician - 浏览技师服务并直接咨询。
  63. /technician_profile - 管理技师服务资料和套餐。
  64. /review_teacher - 主动评价技师。
  65. /technician_top - 查看审核评价排行。
  66. 旧 /teacher_top、/teacher_profile 命令继续兼容。"""
  67. ORDER_LABELS = {
  68. "requested": "待技师处理",
  69. "quoted": "待顾客确认报价",
  70. "confirmed": "已确认,待开始服务",
  71. "in_progress": "服务中",
  72. "completion_pending": "待顾客扫码确认",
  73. "completed": "已完成",
  74. "rejected": "技师已拒绝",
  75. "canceled_customer": "顾客已取消",
  76. "canceled_technician": "技师已取消",
  77. "disputed": "争议处理中",
  78. "voided": "管理员已作废",
  79. }
  80. MODE_LABELS = {"at_store": "到店", "onsite": "上门"}
  81. PRICE_MODE_LABELS = {
  82. "fixed": "固定价",
  83. "starting_at": "起步价",
  84. "range": "价格区间",
  85. "negotiable": "面议",
  86. }
  87. PRICE_UNIT_LABELS = {
  88. "per_service": "次",
  89. "per_hour": "小时",
  90. "per_item": "件",
  91. "per_visit": "次上门",
  92. }
  93. TRAVEL_FEE_LABELS = {
  94. "included": "已包含",
  95. "quoted": "最终报价确认",
  96. }
  97. PRICE_MODE_INPUTS = {
  98. **{key: key for key in PRICE_MODE_LABELS},
  99. **{label: key for key, label in PRICE_MODE_LABELS.items()},
  100. }
  101. PRICE_UNIT_INPUTS = {
  102. **{key: key for key in PRICE_UNIT_LABELS},
  103. "每次": "per_service",
  104. "每小时": "per_hour",
  105. "每件": "per_item",
  106. "每次上门": "per_visit",
  107. }
  108. TRAVEL_FEE_INPUTS = {
  109. "included": "included",
  110. "fixed": "fixed",
  111. "per_km": "per_km",
  112. "quoted": "quoted",
  113. "已包含": "included",
  114. "固定交通费": "fixed",
  115. "按公里": "per_km",
  116. "报价时确认": "quoted",
  117. }
  118. _flows: dict[int, dict[str, Any]] = {}
  119. _address_redaction_task: asyncio.Task[None] | None = None
  120. def _technician_mini_app_url() -> str:
  121. base_url = str(
  122. getattr(wbb, "SERVICE_TECHNICIAN_MINI_APP_URL", "") or ""
  123. ).strip()
  124. if not base_url:
  125. return ""
  126. separator = "&" if "?" in base_url else "?"
  127. bot_id = quote(str(getattr(wbb, "BOT_PROFILE_ID", "primary")), safe="")
  128. return f"{base_url}{separator}bot_id={bot_id}"
  129. async def _address_redaction_sweeper() -> None:
  130. while True:
  131. try:
  132. await redact_expired_addresses()
  133. except Exception as exc:
  134. log.error(f"服务地址脱敏任务失败:{type(exc).__name__}")
  135. await asyncio.sleep(3600)
  136. def start_address_redaction_sweeper() -> None:
  137. global _address_redaction_task
  138. if _address_redaction_task and not _address_redaction_task.done():
  139. return
  140. try:
  141. loop = asyncio.get_running_loop()
  142. _address_redaction_task = loop.create_task(
  143. _address_redaction_sweeper(),
  144. name="service-address-redaction-sweeper",
  145. )
  146. except RuntimeError:
  147. _address_redaction_task = None
  148. async def stop_address_redaction_sweeper() -> None:
  149. global _address_redaction_task
  150. if _address_redaction_task:
  151. _address_redaction_task.cancel()
  152. with suppress(asyncio.CancelledError):
  153. await _address_redaction_task
  154. _address_redaction_task = None
  155. def _flow(user_id: int, stage: str, **values: Any) -> dict[str, Any]:
  156. user_id = int(user_id)
  157. value = {
  158. "stage": stage,
  159. "expires_at": datetime.now(UTC) + timedelta(minutes=20),
  160. **values,
  161. }
  162. _flows[user_id] = value
  163. try:
  164. loop = asyncio.get_running_loop()
  165. def expire() -> None:
  166. if _flows.get(user_id) is value:
  167. _flows.pop(user_id, None)
  168. loop.call_later(20 * 60, expire)
  169. except RuntimeError:
  170. pass
  171. return value
  172. def _current_flow(user_id: int) -> dict[str, Any] | None:
  173. flow = _flows.get(int(user_id))
  174. if flow and flow["expires_at"] >= datetime.now(UTC):
  175. return flow
  176. _flows.pop(int(user_id), None)
  177. return None
  178. def _location_keyboard(label: str) -> ReplyKeyboardMarkup:
  179. return ReplyKeyboardMarkup(
  180. [[KeyboardButton(label, request_location=True)]],
  181. resize_keyboard=True,
  182. one_time_keyboard=True,
  183. selective=True,
  184. )
  185. def _short(value: Any, limit: int) -> str:
  186. text = str(value or "").strip()
  187. return text if len(text) <= limit else f"{text[: limit - 1]}…"
  188. def _package_price_text(package: dict[str, Any]) -> str:
  189. if package.get("price_display"):
  190. return str(package["price_display"])
  191. mode = package.get("price_mode")
  192. currency = package.get("currency") or ""
  193. minimum = package.get("min_price") or "0"
  194. maximum = package.get("max_price") or minimum
  195. unit = PRICE_UNIT_LABELS.get(package.get("price_unit"), "次")
  196. if mode == "negotiable":
  197. return "面议"
  198. if mode == "starting_at":
  199. return f"{currency} {minimum} 起/{unit}"
  200. if mode == "range":
  201. return f"{currency} {minimum}–{maximum}/{unit}"
  202. return f"{currency} {minimum}/{unit}"
  203. def _travel_fee_text(package: dict[str, Any]) -> str:
  204. travel = package.get("travel_fee") or {}
  205. mode = travel.get("mode")
  206. if mode == "fixed":
  207. return f"{package.get('currency', '')} {travel.get('amount') or '0'}"
  208. if mode == "per_km":
  209. return f"{package.get('currency', '')} {travel.get('per_km') or '0'}/公里"
  210. return TRAVEL_FEE_LABELS.get(mode, "最终报价确认")
  211. async def _send_message(target: Message | CallbackQuery, text: str, **kwargs: Any):
  212. message = target.message if isinstance(target, CallbackQuery) else target
  213. return await message.reply_text(text, **kwargs)
  214. async def _notify(user_id: int, text: str, **kwargs: Any) -> None:
  215. try:
  216. await app.send_message(int(user_id), text, **kwargs)
  217. except Exception:
  218. pass
  219. async def _require_terms(target: Message | CallbackQuery) -> bool:
  220. if not target.from_user:
  221. return False
  222. await observe_customer(target.from_user)
  223. try:
  224. await require_customer_allowed(int(target.from_user.id))
  225. return True
  226. except ServiceDataError as exc:
  227. if exc.code != "terms_required":
  228. await _send_message(target, str(exc))
  229. return False
  230. await _send_message(
  231. target,
  232. "<b>使用技师咨询与评价前请确认</b>\n\n"
  233. "平台提供技师信息展示、咨询入口和公开评价,不处理预约、支付或退款。"
  234. "服务细节和具体位置由双方在 Telegram 私聊中沟通。"
  235. "\n\n点击即表示你接受服务规则和隐私说明。",
  236. parse_mode=ParseMode.HTML,
  237. reply_markup=InlineKeyboardMarkup(
  238. [[InlineKeyboardButton("接受并继续", callback_data="svc:terms")]]
  239. ),
  240. )
  241. return False
  242. async def send_service_home(target: Message | CallbackQuery) -> None:
  243. if not target.from_user:
  244. return
  245. if isinstance(target, Message):
  246. await remove_reply_keyboard(target)
  247. if not await _require_terms(target):
  248. return
  249. await _send_message(
  250. target,
  251. "<b>技师服务咨询</b>\n浏览技师的服务、价格和公开评价,需要时直接私聊咨询。",
  252. parse_mode=ParseMode.HTML,
  253. reply_markup=InlineKeyboardMarkup(
  254. [
  255. [InlineKeyboardButton("浏览技师", callback_data="svc:find")],
  256. [InlineKeyboardButton("主动评价", callback_data="svc:review_start")],
  257. [InlineKeyboardButton("审核评价排行", callback_data="svc:top:all:1")],
  258. ]
  259. ),
  260. )
  261. async def _ask_search_location(target: Message | CallbackQuery) -> None:
  262. if not target.from_user or not await _require_terms(target):
  263. return
  264. await _show_nearby(
  265. target,
  266. longitude=None,
  267. latitude=None,
  268. page=1,
  269. )
  270. async def _show_nearby(
  271. target: Message | CallbackQuery,
  272. *,
  273. longitude: float | None,
  274. latitude: float | None,
  275. page: int = 1,
  276. ) -> None:
  277. if page == 1:
  278. await record_service_funnel_event(target.from_user.id, "directory_viewed")
  279. items, total = await list_available_technicians(
  280. longitude=longitude,
  281. latitude=latitude,
  282. page=page,
  283. page_size=6,
  284. )
  285. if not items:
  286. return await _send_message(target, "当前暂时没有公开接单的技师。")
  287. rows = []
  288. lines = [f"<b>技师目录</b>(共 {total} 位)"]
  289. for item in items:
  290. lines.append(
  291. f"\n<b>{escape(item.get('display_name') or str(item['user_id']))}</b>\n"
  292. f"{escape(item.get('public_area_text') or '服务区域由技师填写')}\n"
  293. f"{escape(item.get('headline') or '')}"
  294. )
  295. rows.append(
  296. [
  297. InlineKeyboardButton(
  298. f"查看 {item.get('display_name') or item['user_id']}",
  299. callback_data=f"svc:tech:{item['user_id']}",
  300. )
  301. ]
  302. )
  303. if page > 1:
  304. rows.append([InlineKeyboardButton("上一页", callback_data=f"svc:near:{page - 1}")])
  305. if page * 6 < total:
  306. rows.append([InlineKeyboardButton("下一页", callback_data=f"svc:near:{page + 1}")])
  307. await _send_message(
  308. target,
  309. "\n".join(lines),
  310. parse_mode=ParseMode.HTML,
  311. reply_markup=InlineKeyboardMarkup(rows),
  312. )
  313. async def _show_technician(target: Message | CallbackQuery, technician_id: int) -> None:
  314. if not await _require_terms(target):
  315. return
  316. await record_service_funnel_event(target.from_user.id, "technician_viewed")
  317. profile = await get_technician_service_profile(technician_id)
  318. service_profile = profile.get("service_profile") or {}
  319. if (
  320. profile.get("application_status") != "approved"
  321. or not profile.get("listed")
  322. or not service_profile.get("is_complete")
  323. or not service_profile.get("accepting_requests", True)
  324. ):
  325. raise ServiceDataError("technician_unavailable", "该技师当前未公开接单。")
  326. lines = [
  327. f"<b>{escape(profile.get('display_name') or str(technician_id))}</b>",
  328. escape(service_profile.get("headline") or ""),
  329. escape(_short(service_profile.get("bio"), 300)),
  330. f"服务区域:{escape(service_profile.get('public_area_text') or '由技师填写')}",
  331. "",
  332. "<b>服务套餐</b>",
  333. ]
  334. review_summary = await get_technician_review_summary(technician_id)
  335. lines.insert(
  336. 4,
  337. (
  338. f"审核评价:{review_summary['average_score']:.2f} 分 · "
  339. f"{review_summary['review_count']} 条"
  340. if review_summary["review_count"]
  341. else "审核评价:暂无"
  342. ),
  343. )
  344. rows = [
  345. [
  346. InlineKeyboardButton(
  347. "直接咨询技师",
  348. url=f"https://t.me/{profile['username']}",
  349. ),
  350. InlineKeyboardButton(
  351. "评价该技师",
  352. callback_data=f"svc:review:{technician_id}",
  353. ),
  354. ]
  355. ]
  356. for package in service_profile.get("packages", []):
  357. extra_fee = str(
  358. (package.get("travel_fee") or {}).get("description") or ""
  359. ).strip()
  360. if not extra_fee:
  361. extra_fee = _travel_fee_text(package)
  362. package_lines = [
  363. f"\n<b>{escape(package['name'])}</b>\n"
  364. f"服务内容:{escape(_short(package['description'], 180))}\n"
  365. f"服务价格:{escape(_package_price_text(package))}\n"
  366. f"服务时长:约 {package.get('duration_minutes') or '未设'} 分钟\n"
  367. f"额外费用:{escape(_short(extra_fee, 120))}",
  368. ]
  369. lines.append("\n".join(package_lines))
  370. topic = await get_technician_review_topic(technician_id)
  371. if not topic:
  372. try:
  373. topic = await ensure_technician_review_topic(
  374. str(getattr(app, "bot_token", "") or ""),
  375. technician_id,
  376. )
  377. except Exception:
  378. topic = None
  379. if topic and topic.get("topic_url"):
  380. rows.append(
  381. [
  382. InlineKeyboardButton(
  383. "查看全部公开评价",
  384. url=str(topic["topic_url"]),
  385. )
  386. ]
  387. )
  388. public_reviews, _ = await list_public_reviews(technician_id, page_size=3)
  389. if public_reviews:
  390. lines.append("\n<b>最近审核评价</b>")
  391. for review in public_reviews:
  392. choice_values = []
  393. for value in (review.get("choice_answers") or {}).values():
  394. choice_values.extend(value if isinstance(value, list) else [value])
  395. comment = ";".join(
  396. [
  397. *(str(value) for value in choice_values),
  398. *(review.get("text_answers", {}).values()),
  399. ]
  400. ) or "未填写补充内容"
  401. lines.append(
  402. f"{escape(review['source_label'])} · {review['score']:.2f} 分 · "
  403. f"{escape(review.get('customer_name') or '匿名顾客')}\n"
  404. f"{escape(comment[:120])}"
  405. )
  406. await _send_message(
  407. target,
  408. "\n".join(lines),
  409. parse_mode=ParseMode.HTML,
  410. reply_markup=InlineKeyboardMarkup(rows),
  411. )
  412. async def _choose_request_mode(
  413. query: CallbackQuery, technician_id: int, package_id: str
  414. ) -> None:
  415. profile = await get_technician_service_profile(technician_id)
  416. package = next(
  417. (
  418. item
  419. for item in (profile.get("service_profile") or {}).get("packages", [])
  420. if item.get("package_id") == package_id
  421. ),
  422. None,
  423. )
  424. if not package:
  425. raise ServiceDataError("package_not_found", "套餐不存在或已下架。")
  426. await record_service_funnel_event(query.from_user.id, "request_started")
  427. rows = [
  428. [
  429. InlineKeyboardButton(
  430. MODE_LABELS[mode],
  431. callback_data=f"svc:mode:{technician_id}:{package_id}:{mode}",
  432. )
  433. ]
  434. for mode in package["service_modes"]
  435. ]
  436. await query.message.reply_text(
  437. f"已选择「{package['name']}」,请选择服务方式。报价确认前不会展示精确地址。",
  438. reply_markup=InlineKeyboardMarkup(rows),
  439. )
  440. async def _request_mode(
  441. query: CallbackQuery,
  442. technician_id: int,
  443. package_id: str,
  444. service_mode: str,
  445. ) -> None:
  446. current = _current_flow(query.from_user.id) or {}
  447. values = {
  448. "technician_id": technician_id,
  449. "package_id": package_id,
  450. "service_mode": service_mode,
  451. }
  452. if service_mode == "onsite":
  453. _flow(query.from_user.id, "request_location", **values)
  454. await query.message.reply_text(
  455. "请发送上门位置。精确坐标会单独加密,报价确认前技师只能看到距离区间。",
  456. reply_markup=_location_keyboard("发送上门位置"),
  457. )
  458. else:
  459. if "longitude" not in current or "latitude" not in current:
  460. raise ServiceDataError(
  461. "customer_location_required", "位置已过期,请重新查找附近技师。"
  462. )
  463. values.update(
  464. {
  465. "longitude": current["longitude"],
  466. "latitude": current["latitude"],
  467. }
  468. )
  469. _flow(query.from_user.id, "request_requirements", **values)
  470. await query.message.reply_text("请描述服务需求和补充说明。")
  471. async def _preview_request(message: Message, flow: dict[str, Any]) -> None:
  472. profile = await get_technician_service_profile(flow["technician_id"])
  473. package = next(
  474. item
  475. for item in profile["service_profile"]["packages"]
  476. if item["package_id"] == flow["package_id"]
  477. )
  478. text = (
  479. "<b>确认服务请求</b>\n"
  480. f"技师:{escape(profile.get('display_name') or str(profile['user_id']))}\n"
  481. f"套餐:{escape(package['name'])}\n"
  482. f"方式:{MODE_LABELS[flow['service_mode']]}\n"
  483. f"需求:{escape(flow['requirements'])}\n\n"
  484. "提交后等待技师拒绝或给出最终报价。"
  485. )
  486. await message.reply_text(
  487. text,
  488. parse_mode=ParseMode.HTML,
  489. reply_markup=InlineKeyboardMarkup(
  490. [
  491. [InlineKeyboardButton("确认提交", callback_data="svc:request_submit")],
  492. [InlineKeyboardButton("取消", callback_data="svc:flow_cancel")],
  493. ]
  494. ),
  495. )
  496. def _order_text(order: dict[str, Any]) -> str:
  497. quote = order.get("quote_snapshot") or {}
  498. lines = [
  499. f"<b>服务单 {escape(order['order_id'][:8])}</b>",
  500. f"状态:{ORDER_LABELS.get(order['status'], order['status'])}",
  501. f"技师:{escape(order.get('technician_name') or str(order['technician_id']))}",
  502. f"顾客:{escape(order.get('customer_name') or str(order['customer_id']))}",
  503. f"套餐:{escape((order.get('package_snapshot') or {}).get('name') or '')}",
  504. f"方式:{MODE_LABELS.get(order['service_mode'], order['service_mode'])}",
  505. f"距离:{escape(order.get('distance_band') or '')}",
  506. f"需求:{escape(order.get('requirements') or '')}",
  507. ]
  508. if quote:
  509. lines.extend(
  510. [
  511. "",
  512. "<b>最终报价</b>",
  513. f"服务金额:{quote['currency']} {quote['base_amount']}",
  514. f"交通费:{quote['travel_fee']}",
  515. f"优惠:{quote['discount']}",
  516. f"总价:<b>{quote['currency']} {quote['total_amount']}</b>",
  517. f"说明:{escape(quote.get('note') or '无')}",
  518. ]
  519. )
  520. return "\n".join(lines)
  521. def _order_markup(order: dict[str, Any], actor_id: int) -> InlineKeyboardMarkup | None:
  522. is_customer = actor_id == int(order["customer_id"])
  523. is_technician = actor_id == int(order["technician_id"])
  524. status = order["status"]
  525. rows: list[list[InlineKeyboardButton]] = []
  526. if status in {"requested", "quoted"} and is_technician:
  527. rows.append(
  528. [
  529. InlineKeyboardButton("提交最终报价", callback_data=f"svc:quote:{order['order_id']}"),
  530. InlineKeyboardButton("拒绝", callback_data=f"svc:reject:{order['order_id']}"),
  531. ]
  532. )
  533. if status == "quoted" and is_customer:
  534. rows.append(
  535. [InlineKeyboardButton("确认最终报价", callback_data=f"svc:confirm:{order['order_id']}")]
  536. )
  537. if status == "confirmed" and is_technician:
  538. rows.append(
  539. [InlineKeyboardButton("开始服务", callback_data=f"svc:start:{order['order_id']}")]
  540. )
  541. if status == "in_progress" and is_technician:
  542. rows.append(
  543. [InlineKeyboardButton("完成并生成评价二维码", callback_data=f"svc:finish:{order['order_id']}")]
  544. )
  545. if status == "completion_pending" and is_technician:
  546. rows.append(
  547. [InlineKeyboardButton("二维码过期后重新生成", callback_data=f"svc:finish:{order['order_id']}")]
  548. )
  549. if status in {"confirmed", "in_progress", "completion_pending"}:
  550. other_id = order["technician_id"] if is_customer else order["customer_id"]
  551. rows.append([InlineKeyboardButton("联系对方协商时间", url=f"tg://user?id={other_id}")])
  552. if status in {"requested", "quoted", "confirmed", "in_progress", "completion_pending"} and (
  553. is_customer or is_technician
  554. ):
  555. rows.append(
  556. [
  557. InlineKeyboardButton("取消服务单", callback_data=f"svc:cancel:{order['order_id']}"),
  558. InlineKeyboardButton("发起争议", callback_data=f"svc:dispute:{order['order_id']}"),
  559. ]
  560. )
  561. return InlineKeyboardMarkup(rows) if rows else None
  562. async def _show_order(target: Message | CallbackQuery, order_id: str) -> None:
  563. order = await get_order_for_actor(order_id, target.from_user.id, reveal_address=True)
  564. text = _order_text(order)
  565. exact = order.get("exact_address")
  566. if exact:
  567. text += f"\n\n<b>履约地址</b>\n{escape(exact.get('address_text') or '')}"
  568. await _send_message(
  569. target,
  570. text,
  571. parse_mode=ParseMode.HTML,
  572. reply_markup=_order_markup(order, target.from_user.id),
  573. )
  574. async def _show_orders(target: Message | CallbackQuery, *, role: str, page: int) -> None:
  575. items, total = await list_actor_service_orders(
  576. target.from_user.id, role=role, page=page, page_size=8
  577. )
  578. if not items:
  579. return await _send_message(target, "暂时没有服务单。")
  580. rows = [
  581. [
  582. InlineKeyboardButton(
  583. f"{item['order_id'][:8]} · {ORDER_LABELS.get(item['status'], item['status'])}",
  584. callback_data=f"svc:order:{item['order_id']}",
  585. )
  586. ]
  587. for item in items
  588. ]
  589. navigation = []
  590. if page > 1:
  591. navigation.append(
  592. InlineKeyboardButton("上一页", callback_data=f"svc:orders:{role}:{page - 1}")
  593. )
  594. if page * 8 < total:
  595. navigation.append(
  596. InlineKeyboardButton("下一页", callback_data=f"svc:orders:{role}:{page + 1}")
  597. )
  598. if navigation:
  599. rows.append(navigation)
  600. await _send_message(
  601. target,
  602. f"<b>我的服务单</b>(共 {total} 单)",
  603. parse_mode=ParseMode.HTML,
  604. reply_markup=InlineKeyboardMarkup(rows),
  605. )
  606. async def _start_quote(target: Message | CallbackQuery, order_id: str) -> None:
  607. actor_id = target.from_user.id
  608. order = await get_order_for_actor(order_id, actor_id)
  609. if actor_id != order["technician_id"] or order["status"] not in {
  610. "requested",
  611. "quoted",
  612. }:
  613. raise ServiceDataError("invalid_order_state", "当前服务单不能报价。")
  614. _flow(actor_id, "quote_base", order=order, quote={})
  615. reference = order["package_snapshot"]
  616. await _send_message(
  617. target,
  618. f"套餐参考价:{reference['currency']} {reference['min_price']} - {reference['max_price']}。\n"
  619. "第 1/5 步:请输入本次服务金额。"
  620. )
  621. def _parse_addons(text: str) -> list[dict[str, Any]]:
  622. if text.strip() in {"跳过", "无", "0"}:
  623. return []
  624. addons = []
  625. for raw in text.replace(";", ";").split(";"):
  626. values = [item.strip() for item in raw.split("|")]
  627. if len(values) != 3:
  628. raise ServiceDataError("invalid_addons", "加项格式应为:名称|数量|单价。")
  629. addons.append({"name": values[0], "quantity": int(values[1]), "unit_price": values[2]})
  630. return addons
  631. async def _preview_quote(message: Message, flow: dict[str, Any]) -> None:
  632. quote = normalize_quote(flow["quote"], package=flow["order"]["package_snapshot"])
  633. flow["normalized_quote"] = quote
  634. flow["stage"] = "quote_preview"
  635. await message.reply_text(
  636. "<b>确认最终报价</b>\n"
  637. f"服务金额:{quote['currency']} {quote['base_amount']}\n"
  638. f"交通费:{quote['travel_fee']}\n"
  639. f"加项数量:{len(quote['addons'])}\n"
  640. f"优惠:{quote['discount']}\n"
  641. f"总价:<b>{quote['currency']} {quote['total_amount']}</b>\n"
  642. f"说明:{escape(quote['note'] or '无')}\n\n"
  643. "顾客确认后不能原地改价;如需改价必须取消并重新发起服务单。",
  644. parse_mode=ParseMode.HTML,
  645. reply_markup=InlineKeyboardMarkup(
  646. [
  647. [InlineKeyboardButton("提交报价", callback_data="svc:quote_submit")],
  648. [InlineKeyboardButton("取消", callback_data="svc:flow_cancel")],
  649. ]
  650. ),
  651. )
  652. async def _issue_qr(target: Message | CallbackQuery, order_id: str) -> None:
  653. qr_record, token = await issue_review_qr(order_id, target.from_user.id)
  654. deep_link = f"https://t.me/{BOT_USERNAME}?start=review_{token}"
  655. image = qrcode.make(deep_link)
  656. output = BytesIO()
  657. output.name = f"service-{order_id[:8]}-review.png"
  658. image.save(output, format="PNG")
  659. output.seek(0)
  660. await app.send_photo(
  661. target.from_user.id,
  662. photo=output,
  663. caption=(
  664. f"服务单 {order_id[:8]} 的完成评价二维码\n"
  665. f"仅顾客 {qr_record['customer_id']} 可领取,{qr_record['expires_at']:%Y-%m-%d %H:%M} UTC 前有效。\n\n"
  666. f"备用链接:{deep_link}"
  667. ),
  668. )
  669. await _notify(
  670. qr_record["customer_id"],
  671. f"技师已发起服务完成确认。请扫码或点击备用链接确认:{deep_link}",
  672. )
  673. async def open_review_qr(message: Message, token: str) -> None:
  674. if not message.from_user or not await _require_terms(message):
  675. return
  676. try:
  677. record = await preview_review_qr(token, message.from_user.id)
  678. except ServiceDataError as exc:
  679. return await message.reply_text(str(exc))
  680. if record.get("status") == "claimed":
  681. await message.reply_text("服务单已确认完成,继续填写评价。")
  682. return await _begin_review(
  683. message,
  684. technician_id=record["technician_id"],
  685. source="qr_verified",
  686. qr_id=record["qr_id"],
  687. )
  688. quote = record.get("quote_snapshot") or {}
  689. await message.reply_text(
  690. "<b>确认服务完成</b>\n"
  691. f"服务单:{escape(record['order_id'][:8])}\n"
  692. f"套餐:{escape((record.get('package_snapshot') or {}).get('name') or '')}\n"
  693. f"最终报价:{quote.get('currency', '')} {quote.get('total_amount', '')}\n\n"
  694. "确认后服务单将进入已完成,并立即打开评价模板。",
  695. parse_mode=ParseMode.HTML,
  696. reply_markup=InlineKeyboardMarkup(
  697. [[InlineKeyboardButton("确认完成并评价", callback_data=f"svc:claim:{token}")]]
  698. ),
  699. )
  700. async def _begin_review(
  701. target: Message | CallbackQuery,
  702. *,
  703. technician_id: int,
  704. source: str,
  705. package_id: str | None = None,
  706. qr_id: str | None = None,
  707. ) -> None:
  708. template = await get_active_review_template()
  709. draft = await begin_review_draft(
  710. customer_id=target.from_user.id,
  711. technician_id=technician_id,
  712. source=source,
  713. package_id=package_id,
  714. qr_id=qr_id,
  715. template=template,
  716. )
  717. _flow(
  718. target.from_user.id,
  719. "review_question",
  720. technician_id=technician_id,
  721. source=source,
  722. package_id=package_id,
  723. qr_id=qr_id,
  724. template=draft["template_snapshot"],
  725. question_index=int(
  726. draft.get("question_index")
  727. or draft.get("rating_index")
  728. or 0
  729. ),
  730. answers=dict(draft.get("answers") or {}),
  731. )
  732. await _ask_review_question(target)
  733. def _review_options_markup(
  734. question: dict[str, Any],
  735. selected: list[str] | None = None,
  736. ) -> InlineKeyboardMarkup:
  737. selected_values = set(selected or [])
  738. action = "single" if question["type"] == "single_choice" else "multi"
  739. buttons = []
  740. for index, option in enumerate(question["options"]):
  741. prefix = "✓ " if str(option["value"]) in selected_values else ""
  742. buttons.append(
  743. InlineKeyboardButton(
  744. f"{prefix}{option['label']}",
  745. callback_data=f"svc:{action}:{index}",
  746. )
  747. )
  748. rows = [buttons[index : index + 2] for index in range(0, len(buttons), 2)]
  749. if question["type"] == "multiple_choice":
  750. rows.append(
  751. [
  752. InlineKeyboardButton(
  753. "完成选择",
  754. callback_data="svc:multi_done",
  755. )
  756. ]
  757. )
  758. return InlineKeyboardMarkup(rows)
  759. async def _ask_review_question(target: Message | CallbackQuery) -> None:
  760. flow = _current_flow(target.from_user.id)
  761. if not flow:
  762. return
  763. questions = flow["template"]["questions"]
  764. index = flow["question_index"]
  765. if index >= len(questions):
  766. flow["stage"] = "review_anonymous"
  767. return await _send_message(
  768. target,
  769. "评价已填写完成。请选择公开显示方式,提交后由管理员审核。",
  770. reply_markup=InlineKeyboardMarkup(
  771. [
  772. [InlineKeyboardButton("匿名发布(默认)", callback_data="svc:anonymous:1")],
  773. [InlineKeyboardButton("显示昵称", callback_data="svc:anonymous:0")],
  774. ]
  775. ),
  776. )
  777. question = questions[index]
  778. if question["type"] == "text":
  779. flow["stage"] = "review_text"
  780. return await _send_message(
  781. target,
  782. f"评价 {index + 1}/{len(questions)}:<b>{escape(question['label'])}</b>\n"
  783. f"{escape(question.get('description') or '')}\n"
  784. f"最多 {question['max_length']} 字。"
  785. + ("" if question.get("required") else " 可发送“跳过”。"),
  786. parse_mode=ParseMode.HTML,
  787. )
  788. flow["stage"] = "review_question"
  789. selected = flow["answers"].get(question["question_id"])
  790. await _send_message(
  791. target,
  792. f"评价 {index + 1}/{len(questions)}:<b>{escape(question['label'])}</b>\n"
  793. f"{escape(question.get('description') or '')}",
  794. parse_mode=ParseMode.HTML,
  795. reply_markup=_review_options_markup(
  796. question,
  797. selected if isinstance(selected, list) else None,
  798. ),
  799. )
  800. async def _submit_pending_review(query: CallbackQuery, anonymous: bool) -> None:
  801. flow = _current_flow(query.from_user.id)
  802. if not flow or flow["stage"] != "review_anonymous":
  803. raise ServiceDataError("review_flow_expired", "评价流程已过期,请重新发起。")
  804. review = await submit_review(
  805. customer=query.from_user,
  806. technician_id=flow["technician_id"],
  807. source=flow["source"],
  808. answers=flow["answers"],
  809. anonymous=anonymous,
  810. package_id=flow.get("package_id"),
  811. qr_id=flow.get("qr_id"),
  812. template_snapshot=flow["template"],
  813. )
  814. await delete_review_draft(query.from_user.id)
  815. _flows.pop(query.from_user.id, None)
  816. await query.message.reply_text(
  817. f"评价 {review['review_id'][:8]} 已提交审核。审核通过后会立即发布到该技师的公开评价话题。"
  818. )
  819. async def _choose_review_package(target: Message | CallbackQuery, technician_id: int) -> None:
  820. profile = await get_technician_service_profile(technician_id)
  821. packages = (profile.get("service_profile") or {}).get("packages", [])
  822. if not packages:
  823. raise ServiceDataError("package_not_found", "该技师暂无可评价套餐。")
  824. await _begin_review(
  825. target,
  826. technician_id=technician_id,
  827. source="student_initiated",
  828. )
  829. async def _show_top(target: Message | CallbackQuery, category: str, page: int) -> None:
  830. items, total = await list_leaderboard(
  831. category="" if category == "all" else category,
  832. page=page,
  833. page_size=10,
  834. )
  835. lines = [
  836. "<b>审核评价排行</b>",
  837. "每条管理员审核通过的评价都会公开并参与排行。",
  838. "公式:v/(v+5)×技师平均分 + 5/(v+5)×分类平均分。",
  839. ]
  840. for index, item in enumerate(items, start=(page - 1) * 10 + 1):
  841. lines.append(
  842. f"\n{index}. <b>{escape(item.get('display_name') or str(item['technician_id']))}</b>"
  843. f" · {escape(item['category'])}\n"
  844. f"排行分 {item['rank_score']:.2f} · 平均 {item['average_score']:.2f} · {item['review_count']} 条"
  845. )
  846. if not items:
  847. lines.append("\n暂时还没有审核通过的评价。")
  848. rows = []
  849. if page > 1:
  850. rows.append([InlineKeyboardButton("上一页", callback_data=f"svc:top:{category}:{page - 1}")])
  851. if page * 10 < total:
  852. rows.append([InlineKeyboardButton("下一页", callback_data=f"svc:top:{category}:{page + 1}")])
  853. await _send_message(
  854. target,
  855. "\n".join(lines),
  856. parse_mode=ParseMode.HTML,
  857. reply_markup=InlineKeyboardMarkup(rows) if rows else None,
  858. )
  859. async def _show_profile(target: Message | CallbackQuery) -> None:
  860. try:
  861. profile = await get_technician_service_profile(target.from_user.id)
  862. except ServiceDataError as exc:
  863. return await _send_message(target, str(exc))
  864. if profile.get("application_status") != "approved":
  865. return await _send_message(target, "只有已通过管理员认证的技师可以发布服务资料。")
  866. mini_app_url = _technician_mini_app_url()
  867. service_profile = profile.get("service_profile") or {}
  868. if service_profile.get("is_complete"):
  869. package_items = service_profile.get("packages", [])
  870. packages = "、".join(item["name"] for item in package_items)
  871. rows = []
  872. if mini_app_url:
  873. rows.append(
  874. [
  875. InlineKeyboardButton(
  876. "快速管理套餐",
  877. web_app=WebAppInfo(url=mini_app_url),
  878. )
  879. ]
  880. )
  881. rows.append(
  882. [
  883. InlineKeyboardButton(
  884. "暂停接单" if service_profile.get("accepting_requests") else "开始接单",
  885. callback_data="svc:profile_toggle",
  886. )
  887. ]
  888. )
  889. if not mini_app_url and len(package_items) < 5:
  890. rows.append(
  891. [InlineKeyboardButton("新增套餐", callback_data="svc:profile_add")]
  892. )
  893. rows.append(
  894. [
  895. InlineKeyboardButton(
  896. (
  897. "使用聊天配置(备用)"
  898. if mini_app_url
  899. else "重建资料与套餐"
  900. ),
  901. callback_data="svc:profile_reconfigure",
  902. )
  903. ]
  904. )
  905. return await _send_message(
  906. target,
  907. "<b>我的技师服务资料</b>\n"
  908. f"简介:{escape(service_profile.get('headline') or '')}\n"
  909. f"区域:{escape(service_profile.get('public_area_text') or '')}\n"
  910. f"接单:{'开启' if service_profile.get('accepting_requests') else '暂停'}\n"
  911. f"套餐:{escape(packages)}",
  912. parse_mode=ParseMode.HTML,
  913. reply_markup=InlineKeyboardMarkup(rows),
  914. )
  915. if mini_app_url:
  916. return await _send_message(
  917. target,
  918. "使用快速版选择模板,只需确认价格、时长和服务区域即可发布。"
  919. "聊天分步配置继续保留为备用入口。",
  920. reply_markup=InlineKeyboardMarkup(
  921. [
  922. [
  923. InlineKeyboardButton(
  924. "快速创建套餐",
  925. web_app=WebAppInfo(url=mini_app_url),
  926. )
  927. ],
  928. [
  929. InlineKeyboardButton(
  930. "使用聊天配置(备用)",
  931. callback_data="svc:profile_reconfigure",
  932. )
  933. ],
  934. ]
  935. ),
  936. )
  937. await _show_profile_sources(target)
  938. async def _show_profile_sources(target: Message | CallbackQuery) -> None:
  939. flow = _current_flow(target.from_user.id)
  940. if not flow or flow.get("stage") != "profile_source":
  941. flow = _flow(target.from_user.id, "profile_source", profile_mode="replace")
  942. profile_mode = flow.get("profile_mode", "replace")
  943. templates = await list_package_templates()
  944. rows = [
  945. [
  946. InlineKeyboardButton(
  947. item["name"], callback_data=f"svc:profile_template:{item['template_id']}"
  948. )
  949. ]
  950. for item in templates
  951. if item.get("status") == "enabled"
  952. ]
  953. rows.append(
  954. [InlineKeyboardButton("完全自建套餐", callback_data="svc:profile_custom")]
  955. )
  956. await _send_message(
  957. target,
  958. (
  959. "选择一个后台套餐模板新增套餐,或完全自建套餐。"
  960. if profile_mode == "append"
  961. else "选择一个后台套餐模板开始,或完全自建套餐。"
  962. )
  963. + "模板只用于预填,发布时会保存独立快照;后台后续修改不会覆盖你的套餐。",
  964. reply_markup=InlineKeyboardMarkup(rows),
  965. )
  966. async def _start_profile_flow(query: CallbackQuery, template_id: str) -> None:
  967. current = _current_flow(query.from_user.id) or {}
  968. package = await instantiate_package_template(template_id)
  969. flow = _flow(
  970. query.from_user.id,
  971. "template_preview",
  972. package=package,
  973. profile={},
  974. profile_mode=current.get("profile_mode", "replace"),
  975. )
  976. await _show_template_package_preview(query, flow)
  977. async def _show_template_package_preview(
  978. target: Message | CallbackQuery,
  979. flow: dict[str, Any],
  980. ) -> None:
  981. package = flow["package"]
  982. flow["stage"] = "template_preview"
  983. await _send_message(
  984. target,
  985. "<b>模板套餐预览</b>\n"
  986. f"名称:{escape(package['name'])}\n"
  987. f"分类:{escape(package['category'])}\n"
  988. f"方式:{'/'.join(MODE_LABELS[item] for item in package['service_modes'])}\n"
  989. f"价格:{package['currency']} {package['min_price']} - {package['max_price']}\n"
  990. f"时长:{package.get('duration_minutes') or '未设'} 分钟\n"
  991. f"说明:{escape(package['description'])}\n\n"
  992. "可只修改需要调整的部分;未修改字段保留模板快照。",
  993. parse_mode=ParseMode.HTML,
  994. reply_markup=InlineKeyboardMarkup(
  995. [
  996. [InlineKeyboardButton("直接使用并填写资料", callback_data="svc:profile_template_use")],
  997. [
  998. InlineKeyboardButton("名称", callback_data="svc:profile_edit:name"),
  999. InlineKeyboardButton("分类", callback_data="svc:profile_edit:category"),
  1000. InlineKeyboardButton("说明", callback_data="svc:profile_edit:description"),
  1001. ],
  1002. [
  1003. InlineKeyboardButton("标签", callback_data="svc:profile_edit:tags"),
  1004. InlineKeyboardButton("方式", callback_data="svc:profile_edit:modes"),
  1005. ],
  1006. [
  1007. InlineKeyboardButton("价格", callback_data="svc:profile_edit:price"),
  1008. InlineKeyboardButton("币种/计价", callback_data="svc:profile_edit:billing"),
  1009. InlineKeyboardButton("时长/半径", callback_data="svc:profile_edit:duration"),
  1010. ],
  1011. [
  1012. InlineKeyboardButton("包含与交通", callback_data="svc:profile_edit:details"),
  1013. InlineKeyboardButton("标准加项", callback_data="svc:profile_edit:addons"),
  1014. ],
  1015. ]
  1016. ),
  1017. )
  1018. async def _start_custom_package_flow(query: CallbackQuery) -> None:
  1019. current = _current_flow(query.from_user.id) or {}
  1020. _flow(
  1021. query.from_user.id,
  1022. "custom_name",
  1023. package={},
  1024. profile={},
  1025. profile_mode=current.get("profile_mode", "replace"),
  1026. )
  1027. await query.message.reply_text("自建套餐 1/9:请输入套餐名称。")
  1028. async def _publish_profile(
  1029. target: Message | CallbackQuery, flow: dict[str, Any]
  1030. ) -> None:
  1031. user_id = target.from_user.id
  1032. if flow.get("profile_mode") == "append":
  1033. current = await get_technician_service_profile(user_id)
  1034. current_values = current.get("service_profile") or {}
  1035. packages = list(current_values.get("packages", []))
  1036. if len(packages) >= 5:
  1037. raise ServiceDataError("invalid_package_count", "每位技师最多发布 5 个套餐。")
  1038. packages.append(flow["package"])
  1039. values = {
  1040. "headline": current_values.get("headline"),
  1041. "bio": current_values.get("bio"),
  1042. "contact_hours": current_values.get("contact_hours"),
  1043. "public_area_text": current_values.get("public_area_text"),
  1044. "tags": current_values.get("tags", []),
  1045. "venue": current_values.get("venue", {}),
  1046. "onsite_policy": current_values.get("onsite_policy", {}),
  1047. "packages": packages,
  1048. "accepting_requests": current_values.get("accepting_requests", True),
  1049. }
  1050. else:
  1051. profile_values = dict(flow["profile"])
  1052. values = {
  1053. "headline": profile_values["headline"],
  1054. "bio": profile_values["bio"],
  1055. "contact_hours": profile_values["contact_hours"],
  1056. "public_area_text": profile_values["public_area_text"],
  1057. "tags": flow["package"].get("tags", []),
  1058. "venue": {
  1059. "name": profile_values["venue_name"],
  1060. "address_hint": profile_values["venue_address"],
  1061. },
  1062. "onsite_policy": {"description": profile_values["onsite_description"]},
  1063. "packages": [flow["package"]],
  1064. "accepting_requests": True,
  1065. }
  1066. profile = await publish_technician_profile(
  1067. user_id, values, actor_id=user_id
  1068. )
  1069. with suppress(Exception):
  1070. await ensure_technician_review_topic(
  1071. str(getattr(app, "bot_token", "") or ""),
  1072. user_id,
  1073. )
  1074. _flows.pop(user_id, None)
  1075. await _send_message(
  1076. target,
  1077. f"服务资料已发布,共 {len(profile['service_profile']['packages'])} 个套餐。"
  1078. "套餐已保存模板版本和完整快照。"
  1079. )
  1080. @app.on_message(filters.private & filters.command("find_technician"))
  1081. async def find_technician_command(_, message: Message):
  1082. await _ask_search_location(message)
  1083. @app.on_message(filters.private & filters.command(["my_services", "my_orders"]))
  1084. async def my_services_command(_, message: Message):
  1085. if await _require_terms(message):
  1086. await _show_orders(message, role="all", page=1)
  1087. @app.on_message(filters.private & filters.command(["technician_top", "teacher_top"]))
  1088. async def technician_top_command(_, message: Message):
  1089. if not await _require_terms(message):
  1090. return
  1091. category = message.command[1] if len(message.command) > 1 else "all"
  1092. await _show_top(message, category, 1)
  1093. @app.on_message(filters.private & filters.command(["technician_profile", "teacher_profile"]))
  1094. async def technician_profile_command(_, message: Message):
  1095. await _show_profile(message)
  1096. @app.on_message(filters.private & filters.command("review_teacher"))
  1097. async def review_teacher_command(_, message: Message):
  1098. if not await _require_terms(message):
  1099. return
  1100. if len(message.command) < 2 or not message.command[1].isdigit():
  1101. return await message.reply_text("请使用 /review_teacher 技师ID,或从技师详情点击“主动评价”。")
  1102. await _choose_review_package(message, int(message.command[1]))
  1103. @app.on_message(filters.private & filters.command("review_qr"))
  1104. async def review_qr_command(_, message: Message):
  1105. if len(message.command) < 2:
  1106. return await message.reply_text("请使用 /review_qr 服务单ID,或在服务单详情点击生成二维码。")
  1107. try:
  1108. await _issue_qr(message, message.command[1])
  1109. except ServiceDataError as exc:
  1110. await message.reply_text(str(exc))
  1111. @app.on_message(filters.private & filters.command("block_customer"))
  1112. async def block_customer_command(_, message: Message):
  1113. if len(message.command) < 3 or not message.command[1].isdigit():
  1114. return await message.reply_text("请使用 /block_customer 顾客ID 原因。")
  1115. try:
  1116. blocked = await set_technician_customer_block(
  1117. technician_id=message.from_user.id,
  1118. customer_id=int(message.command[1]),
  1119. active=True,
  1120. reason=" ".join(message.command[2:]),
  1121. )
  1122. await message.reply_text(f"已拉黑顾客 {blocked['customer_id']},之后的新请求会被拒绝。")
  1123. except ServiceDataError as exc:
  1124. await message.reply_text(str(exc))
  1125. @app.on_message(filters.private & filters.command("report_service"))
  1126. async def report_service_command(_, message: Message):
  1127. if len(message.command) < 3:
  1128. return await message.reply_text("请使用 /report_service 服务单ID 举报原因。")
  1129. order_id = message.command[1]
  1130. try:
  1131. await get_order_for_actor(order_id, message.from_user.id)
  1132. report = await create_service_report(
  1133. reporter_id=message.from_user.id,
  1134. target_type="order",
  1135. target_id=order_id,
  1136. reason=" ".join(message.command[2:]),
  1137. )
  1138. await message.reply_text(f"举报 {report['report_id'][:8]} 已提交后台处理。")
  1139. except ServiceDataError as exc:
  1140. await message.reply_text(str(exc))
  1141. @app.on_message(filters.private & filters.location, group=9)
  1142. async def service_location_message(_, message: Message):
  1143. if not message.from_user or not message.location:
  1144. return
  1145. flow = _current_flow(message.from_user.id)
  1146. if not flow or flow["stage"] not in {"search_location", "request_location"}:
  1147. return
  1148. longitude = message.location.longitude
  1149. latitude = message.location.latitude
  1150. await remove_reply_keyboard(message)
  1151. if flow["stage"] == "search_location":
  1152. await _show_nearby(message, longitude=longitude, latitude=latitude)
  1153. else:
  1154. flow.update(
  1155. {
  1156. "longitude": longitude,
  1157. "latitude": latitude,
  1158. "stage": "request_address",
  1159. }
  1160. )
  1161. await message.reply_text("请填写上门精确地址(门牌号/楼层/房间)。该文本会加密保存。")
  1162. raise StopPropagation
  1163. @app.on_message(filters.private & filters.command("cancel"), group=-30)
  1164. async def cancel_service_flow(_, message: Message):
  1165. if not message.from_user or not _flows.pop(message.from_user.id, None):
  1166. return
  1167. await remove_reply_keyboard(message)
  1168. await message.reply_text("已取消当前技师服务操作。")
  1169. raise StopPropagation
  1170. @app.on_message(filters.private & filters.text, group=9)
  1171. async def service_private_text(_, message: Message):
  1172. if not message.from_user:
  1173. return
  1174. flow = _current_flow(message.from_user.id)
  1175. if not flow:
  1176. return
  1177. text = str(message.text or "").strip()
  1178. stage = flow["stage"]
  1179. try:
  1180. if stage == "request_address":
  1181. if len(text) < 3:
  1182. return await message.reply_text("请填写完整精确地址。")
  1183. flow.update({"address_text": text, "stage": "request_requirements"})
  1184. await message.reply_text("请描述服务需求和补充说明。")
  1185. elif stage == "request_requirements":
  1186. flow.update({"requirements": text, "stage": "request_preview"})
  1187. await _preview_request(message, flow)
  1188. elif stage == "quote_base":
  1189. flow["quote"]["base_amount"] = text
  1190. flow["stage"] = "quote_travel"
  1191. await message.reply_text("第 2/5 步:请输入交通费;没有则输入 0。")
  1192. elif stage == "quote_travel":
  1193. flow["quote"]["travel_fee"] = text
  1194. flow["stage"] = "quote_addons"
  1195. await message.reply_text(
  1196. "第 3/5 步:输入加项“名称|数量|单价”,多个用分号分隔;没有则输入“跳过”。"
  1197. )
  1198. elif stage == "quote_addons":
  1199. flow["quote"]["addons"] = _parse_addons(text)
  1200. flow["stage"] = "quote_discount"
  1201. await message.reply_text("第 4/5 步:请输入优惠金额;没有则输入 0。")
  1202. elif stage == "quote_discount":
  1203. flow["quote"]["discount"] = text
  1204. flow["stage"] = "quote_note"
  1205. await message.reply_text("第 5/5 步:请输入报价说明;没有则输入“无”。")
  1206. elif stage == "quote_note":
  1207. flow["quote"]["note"] = "" if text == "无" else text
  1208. await _preview_quote(message, flow)
  1209. elif stage in {"cancel_reason", "dispute_reason", "reject_reason"}:
  1210. order_id = flow["order_id"]
  1211. if stage == "cancel_reason":
  1212. order = await cancel_service_order(order_id, message.from_user.id, text)
  1213. elif stage == "dispute_reason":
  1214. order = await dispute_service_order(order_id, message.from_user.id, text)
  1215. else:
  1216. order = await reject_service_order(order_id, message.from_user.id, text)
  1217. _flows.pop(message.from_user.id, None)
  1218. other_id = (
  1219. order["technician_id"]
  1220. if message.from_user.id == order["customer_id"]
  1221. else order["customer_id"]
  1222. )
  1223. await message.reply_text(f"服务单已更新为:{ORDER_LABELS[order['status']]}。")
  1224. await _notify(other_id, f"服务单 {order_id[:8]} 状态已更新:{ORDER_LABELS[order['status']]}。原因:{text}")
  1225. elif stage == "review_text":
  1226. question = flow["template"]["questions"][flow["question_index"]]
  1227. if text != "跳过" or question.get("required"):
  1228. flow["answers"][question["question_id"]] = "" if text == "跳过" else text
  1229. flow["question_index"] += 1
  1230. await save_review_draft_progress(
  1231. message.from_user.id,
  1232. answers=flow["answers"],
  1233. question_index=flow["question_index"],
  1234. )
  1235. await _ask_review_question(message)
  1236. elif stage == "template_edit_name":
  1237. flow["package"]["name"] = text
  1238. flow["package"]["customized_from_template"] = True
  1239. await _show_template_package_preview(message, flow)
  1240. elif stage == "template_edit_category":
  1241. flow["package"]["category"] = text
  1242. flow["package"]["customized_from_template"] = True
  1243. await _show_template_package_preview(message, flow)
  1244. elif stage == "template_edit_description":
  1245. flow["package"]["description"] = text
  1246. flow["package"]["customized_from_template"] = True
  1247. await _show_template_package_preview(message, flow)
  1248. elif stage == "template_edit_tags":
  1249. flow["package"]["tags"] = text
  1250. flow["package"]["customized_from_template"] = True
  1251. await _show_template_package_preview(message, flow)
  1252. elif stage == "template_edit_modes":
  1253. modes = {
  1254. "到店": ["at_store"],
  1255. "上门": ["onsite"],
  1256. "两者": ["at_store", "onsite"],
  1257. }.get(text)
  1258. if not modes:
  1259. return await message.reply_text("请输入“到店”、“上门”或“两者”。")
  1260. flow["package"]["service_modes"] = modes
  1261. if "onsite" in modes and not flow["package"].get("service_radius_km"):
  1262. flow["package"]["service_radius_km"] = 20
  1263. flow["package"]["customized_from_template"] = True
  1264. await _show_template_package_preview(message, flow)
  1265. elif stage == "template_edit_price":
  1266. values = [item.strip() for item in text.split("|")]
  1267. if len(values) != 3:
  1268. return await message.reply_text("格式为:价格模式|最低价|最高价。")
  1269. price_mode = PRICE_MODE_INPUTS.get(values[0])
  1270. if not price_mode:
  1271. return await message.reply_text(
  1272. "价格模式请选择:固定价、起步价、价格区间或面议。"
  1273. )
  1274. flow["package"].update(
  1275. {
  1276. "price_mode": price_mode,
  1277. "min_price": values[1],
  1278. "max_price": values[2],
  1279. "customized_from_template": True,
  1280. }
  1281. )
  1282. await _show_template_package_preview(message, flow)
  1283. elif stage == "template_edit_billing":
  1284. values = [item.strip() for item in text.split("|")]
  1285. if len(values) != 2:
  1286. return await message.reply_text("格式为:ISO币种|计价单位。")
  1287. price_unit = PRICE_UNIT_INPUTS.get(values[1])
  1288. if not price_unit:
  1289. return await message.reply_text(
  1290. "计价单位请选择:每次、每小时、每件或每次上门。"
  1291. )
  1292. flow["package"].update(
  1293. {
  1294. "currency": values[0],
  1295. "price_unit": price_unit,
  1296. "customized_from_template": True,
  1297. }
  1298. )
  1299. await _show_template_package_preview(message, flow)
  1300. elif stage == "template_edit_duration":
  1301. values = [item.strip() for item in text.split("|")]
  1302. if len(values) != 2:
  1303. return await message.reply_text("格式为:时长分钟|上门半径公里。")
  1304. flow["package"].update(
  1305. {
  1306. "duration_minutes": int(values[0]),
  1307. "service_radius_km": float(values[1]),
  1308. "customized_from_template": True,
  1309. }
  1310. )
  1311. await _show_template_package_preview(message, flow)
  1312. elif stage == "template_edit_details":
  1313. values = [item.strip() for item in text.split("|")]
  1314. if len(values) != 7:
  1315. return await message.reply_text("请按 7 个字段输入,字段内不要使用 |。")
  1316. flow["package"].update(
  1317. {
  1318. "included_items": values[0],
  1319. "excluded_items": values[1],
  1320. "preparation": values[2],
  1321. "out_of_range_policy": values[3],
  1322. "travel_fee": {
  1323. "mode": values[4],
  1324. "amount": values[5],
  1325. "per_km": values[6],
  1326. },
  1327. "customized_from_template": True,
  1328. }
  1329. )
  1330. await _show_template_package_preview(message, flow)
  1331. elif stage == "template_edit_addons":
  1332. addons = []
  1333. if text not in {"无", "清空", "0"}:
  1334. for raw in text.replace(";", ";").split(";"):
  1335. values = [item.strip() for item in raw.split("|")]
  1336. if len(values) != 4:
  1337. return await message.reply_text("加项格式为:名称|单位|价格|额外分钟。")
  1338. addons.append(
  1339. {
  1340. "name": values[0],
  1341. "unit": values[1],
  1342. "price": values[2],
  1343. "extra_minutes": int(values[3]),
  1344. }
  1345. )
  1346. flow["package"]["addons"] = addons
  1347. flow["package"]["customized_from_template"] = True
  1348. await _show_template_package_preview(message, flow)
  1349. elif stage == "custom_name":
  1350. flow["package"]["name"] = text
  1351. flow["stage"] = "custom_category"
  1352. await message.reply_text("自建套餐 2/9:请输入服务分类。")
  1353. elif stage == "custom_category":
  1354. flow["package"]["category"] = text
  1355. flow["stage"] = "custom_description"
  1356. await message.reply_text("自建套餐 3/9:请输入套餐详细说明。")
  1357. elif stage == "custom_description":
  1358. flow["package"]["description"] = text
  1359. flow["stage"] = "custom_tags"
  1360. await message.reply_text("自建套餐 4/9:请输入标签,多个用逗号分隔。")
  1361. elif stage == "custom_tags":
  1362. flow["package"]["tags"] = text
  1363. flow["stage"] = "custom_modes"
  1364. await message.reply_text("自建套餐 5/9:输入“到店”、“上门”或“两者”。")
  1365. elif stage == "custom_modes":
  1366. modes = {
  1367. "到店": ["at_store"],
  1368. "上门": ["onsite"],
  1369. "两者": ["at_store", "onsite"],
  1370. }.get(text)
  1371. if not modes:
  1372. return await message.reply_text("请输入“到店”、“上门”或“两者”。")
  1373. flow["package"]["service_modes"] = modes
  1374. flow["stage"] = "custom_price"
  1375. await message.reply_text(
  1376. "自建套餐 6/9:按“价格模式|币种|最低价|最高价|计价单位”输入。\n"
  1377. "价格模式:固定价/起步价/价格区间/面议;"
  1378. "计价单位:每次/每小时/每件/每次上门。\n"
  1379. "示例:固定价|CNY|200|200|每次"
  1380. )
  1381. elif stage == "custom_price":
  1382. values = [item.strip() for item in text.split("|")]
  1383. if len(values) != 5:
  1384. return await message.reply_text("格式不正确,请按 5 个字段重新输入。")
  1385. raw_price_mode, currency, min_price, max_price, raw_price_unit = values
  1386. price_mode = PRICE_MODE_INPUTS.get(raw_price_mode)
  1387. price_unit = PRICE_UNIT_INPUTS.get(raw_price_unit)
  1388. if not price_mode:
  1389. return await message.reply_text(
  1390. "价格模式不支持,请填写:固定价、起步价、价格区间或面议。"
  1391. )
  1392. if not price_unit:
  1393. return await message.reply_text(
  1394. "计价单位不支持,请填写:每次、每小时、每件或每次上门。"
  1395. )
  1396. flow["package"].update(
  1397. {
  1398. "price_mode": price_mode,
  1399. "currency": currency,
  1400. "min_price": min_price,
  1401. "max_price": max_price,
  1402. "price_unit": price_unit,
  1403. }
  1404. )
  1405. flow["stage"] = "custom_duration"
  1406. await message.reply_text("自建套餐 7/9:输入“默认时长分钟|上门半径公里”;不支持上门时半径填 0。")
  1407. elif stage == "custom_duration":
  1408. values = [item.strip() for item in text.split("|")]
  1409. if len(values) != 2:
  1410. return await message.reply_text("格式不正确,例如:60|20。")
  1411. flow["package"]["duration_minutes"] = int(values[0])
  1412. flow["package"]["service_radius_km"] = (
  1413. float(values[1]) if "onsite" in flow["package"]["service_modes"] else None
  1414. )
  1415. flow["stage"] = "custom_details"
  1416. await message.reply_text("自建套餐 8/9:输入“包含项|不包含项|准备事项|超区说明”。")
  1417. elif stage == "custom_details":
  1418. values = [item.strip() for item in text.split("|")]
  1419. if len(values) != 4:
  1420. return await message.reply_text("格式不正确,请按 4 个字段重新输入。")
  1421. flow["package"].update(
  1422. {
  1423. "included_items": values[0],
  1424. "excluded_items": values[1],
  1425. "preparation": values[2],
  1426. "out_of_range_policy": values[3],
  1427. }
  1428. )
  1429. flow["stage"] = "custom_travel"
  1430. await message.reply_text(
  1431. "自建套餐 9/9:输入“交通费模式|固定金额|每公里金额”。"
  1432. "模式为已包含/固定交通费/按公里/报价时确认,"
  1433. "例如:报价时确认|0|0。"
  1434. )
  1435. elif stage == "custom_travel":
  1436. values = [item.strip() for item in text.split("|")]
  1437. if len(values) != 3:
  1438. return await message.reply_text("格式不正确,请按 3 个字段重新输入。")
  1439. travel_mode = TRAVEL_FEE_INPUTS.get(values[0])
  1440. if not travel_mode:
  1441. return await message.reply_text(
  1442. "交通费请选择:已包含、固定交通费、按公里或报价时确认。"
  1443. )
  1444. flow["package"].update(
  1445. {
  1446. "travel_fee": {
  1447. "mode": travel_mode,
  1448. "amount": values[1],
  1449. "per_km": values[2],
  1450. },
  1451. "addons": [],
  1452. "source_type": "custom",
  1453. }
  1454. )
  1455. if flow.get("profile_mode") == "append":
  1456. await _publish_profile(message, flow)
  1457. else:
  1458. flow["stage"] = "profile_headline"
  1459. await message.reply_text("套餐信息已完成。服务资料第 1/7 步:请输入一句服务介绍(最多 80 字)。")
  1460. elif stage.startswith("profile_"):
  1461. profile = flow["profile"]
  1462. next_steps = {
  1463. "profile_headline": ("headline", "profile_bio", "第 2/7 步:请输入完整服务介绍。"),
  1464. "profile_bio": ("bio", "profile_hours", "第 3/7 步:请输入可联系时段。"),
  1465. "profile_hours": ("contact_hours", "profile_area", "第 4/7 步:请输入公开服务区域描述。"),
  1466. "profile_area": ("public_area_text", "profile_venue", "第 5/7 步:请输入门店/工作地点名称。"),
  1467. "profile_venue": ("venue_name", "profile_address", "第 6/7 步:请输入到店精确地址。"),
  1468. "profile_address": ("venue_address", "profile_onsite", "第 7/7 步:请输入上门范围与超区说明。"),
  1469. }
  1470. if stage in next_steps:
  1471. key, next_stage, prompt = next_steps[stage]
  1472. profile[key] = text
  1473. flow["stage"] = next_stage
  1474. await message.reply_text(prompt)
  1475. elif stage == "profile_onsite":
  1476. profile["onsite_description"] = text
  1477. await _publish_profile(message, flow)
  1478. else:
  1479. return
  1480. except (ServiceDataError, ValueError) as exc:
  1481. await message.reply_text(f"操作未完成:{exc}")
  1482. raise StopPropagation
  1483. @app.on_callback_query(filters.regex(r"^svc:"))
  1484. async def service_callback(_, query: CallbackQuery):
  1485. parts = str(query.data).split(":")
  1486. try:
  1487. action = parts[1]
  1488. if action == "terms":
  1489. await observe_customer(query.from_user, accepted_terms=True)
  1490. await query.answer("已接受。")
  1491. return await send_service_home(query)
  1492. if action == "home":
  1493. return await send_service_home(query)
  1494. if action == "find":
  1495. return await _ask_search_location(query)
  1496. if action == "near":
  1497. return await _show_nearby(
  1498. query,
  1499. longitude=None,
  1500. latitude=None,
  1501. page=int(parts[2]),
  1502. )
  1503. if action == "tech":
  1504. return await _show_technician(query, int(parts[2]))
  1505. if action == "req":
  1506. return await _choose_request_mode(query, int(parts[2]), parts[3])
  1507. if action == "mode":
  1508. return await _request_mode(query, int(parts[2]), parts[3], parts[4])
  1509. if action == "request_submit":
  1510. flow = _current_flow(query.from_user.id)
  1511. if not flow or flow["stage"] != "request_preview":
  1512. raise ServiceDataError("request_flow_expired", "服务请求已过期,请重新发起。")
  1513. order = await create_service_order(customer=query.from_user, **{
  1514. key: flow.get(key)
  1515. for key in (
  1516. "technician_id",
  1517. "package_id",
  1518. "service_mode",
  1519. "requirements",
  1520. "longitude",
  1521. "latitude",
  1522. "address_text",
  1523. )
  1524. })
  1525. _flows.pop(query.from_user.id, None)
  1526. await query.message.reply_text(
  1527. f"服务请求 {order['order_id'][:8]} 已提交,等待技师报价。",
  1528. reply_markup=_order_markup(order, query.from_user.id),
  1529. )
  1530. await _notify(
  1531. order["technician_id"],
  1532. f"收到新的服务请求 {order['order_id'][:8]}。",
  1533. reply_markup=_order_markup(order, order["technician_id"]),
  1534. )
  1535. return await query.answer("已提交。")
  1536. if action == "flow_cancel":
  1537. _flows.pop(query.from_user.id, None)
  1538. await remove_reply_keyboard(query.message)
  1539. await query.message.reply_text("已取消当前操作。")
  1540. return await query.answer()
  1541. if action == "orders":
  1542. return await _show_orders(query, role=parts[2], page=int(parts[3]))
  1543. if action == "order":
  1544. return await _show_order(query, parts[2])
  1545. if action == "quote":
  1546. await _start_quote(query, parts[2])
  1547. return await query.answer()
  1548. if action == "quote_submit":
  1549. flow = _current_flow(query.from_user.id)
  1550. if not flow or flow["stage"] != "quote_preview":
  1551. raise ServiceDataError("quote_flow_expired", "报价流程已过期,请重新发起。")
  1552. order, _ = await submit_service_quote(
  1553. flow["order"]["order_id"], query.from_user.id, flow["normalized_quote"]
  1554. )
  1555. _flows.pop(query.from_user.id, None)
  1556. await query.message.reply_text("最终报价已提交,等待顾客确认。")
  1557. await _notify(order["customer_id"], f"服务单 {order['order_id'][:8]} 已收到最终报价。", reply_markup=_order_markup(order, order["customer_id"]))
  1558. return await query.answer("已提交。")
  1559. if action in {"cancel", "dispute", "reject"}:
  1560. stage = {"cancel": "cancel_reason", "dispute": "dispute_reason", "reject": "reject_reason"}[action]
  1561. _flow(query.from_user.id, stage, order_id=parts[2])
  1562. await query.message.reply_text("请填写原因。该原因会通知对方并保留在状态轨迹中。")
  1563. return await query.answer()
  1564. if action == "confirm":
  1565. order = await confirm_service_quote(parts[2], query.from_user.id)
  1566. await query.message.reply_text("最终报价已确认。精确履约地址和双方联系入口现已解锁。")
  1567. await _notify(order["technician_id"], f"顾客已确认服务单 {order['order_id'][:8]} 的最终报价。", reply_markup=_order_markup(order, order["technician_id"]))
  1568. return await _show_order(query, order["order_id"])
  1569. if action == "start":
  1570. order = await start_service_order(parts[2], query.from_user.id)
  1571. await _notify(order["customer_id"], f"技师已开始服务单 {order['order_id'][:8]}。")
  1572. return await _show_order(query, order["order_id"])
  1573. if action == "finish":
  1574. await _issue_qr(query, parts[2])
  1575. return await query.answer("二维码已生成。")
  1576. if action == "claim":
  1577. record, _ = await claim_review_qr(parts[2], query.from_user.id)
  1578. await query.answer("服务已确认完成。")
  1579. return await _begin_review(
  1580. query,
  1581. technician_id=record["technician_id"],
  1582. source="qr_verified",
  1583. qr_id=record["qr_id"],
  1584. )
  1585. if action in {"review", "review_start"}:
  1586. if action == "review_start":
  1587. return await query.message.reply_text("请从技师详情点击“主动评价”,或使用 /review_teacher 技师ID。")
  1588. return await _choose_review_package(query, int(parts[2]))
  1589. if action == "reviewpkg":
  1590. return await _begin_review(
  1591. query,
  1592. technician_id=int(parts[2]),
  1593. source="student_initiated",
  1594. package_id=parts[3],
  1595. )
  1596. if action in {"single", "rate"}:
  1597. flow = _current_flow(query.from_user.id)
  1598. if not flow or flow["stage"] != "review_question":
  1599. raise ServiceDataError("review_flow_expired", "评价流程已过期。")
  1600. question = flow["template"]["questions"][flow["question_index"]]
  1601. if question["type"] != "single_choice":
  1602. raise ServiceDataError("invalid_review_answer", "当前问题不是单选题。")
  1603. if action == "rate":
  1604. option = next(
  1605. (
  1606. item
  1607. for item in question["options"]
  1608. if int(item.get("score") or 0) == int(parts[2])
  1609. ),
  1610. None,
  1611. )
  1612. else:
  1613. option_index = int(parts[2])
  1614. option = (
  1615. question["options"][option_index]
  1616. if 0 <= option_index < len(question["options"])
  1617. else None
  1618. )
  1619. if not option:
  1620. raise ServiceDataError("invalid_review_answer", "评价选项无效。")
  1621. flow["answers"][question["question_id"]] = option["value"]
  1622. flow["question_index"] += 1
  1623. await save_review_draft_progress(
  1624. query.from_user.id,
  1625. answers=flow["answers"],
  1626. question_index=flow["question_index"],
  1627. )
  1628. await query.answer(f"已选择:{option['label']}")
  1629. return await _ask_review_question(query)
  1630. if action == "multi":
  1631. flow = _current_flow(query.from_user.id)
  1632. if not flow or flow["stage"] != "review_question":
  1633. raise ServiceDataError("review_flow_expired", "评价流程已过期。")
  1634. question = flow["template"]["questions"][flow["question_index"]]
  1635. if question["type"] != "multiple_choice":
  1636. raise ServiceDataError("invalid_review_answer", "当前问题不是多选题。")
  1637. option_index = int(parts[2])
  1638. if not 0 <= option_index < len(question["options"]):
  1639. raise ServiceDataError("invalid_review_answer", "评价选项无效。")
  1640. option_value = str(question["options"][option_index]["value"])
  1641. selected = list(flow["answers"].get(question["question_id"]) or [])
  1642. if option_value in selected:
  1643. selected.remove(option_value)
  1644. else:
  1645. selected.append(option_value)
  1646. flow["answers"][question["question_id"]] = selected
  1647. await save_review_draft_progress(
  1648. query.from_user.id,
  1649. answers=flow["answers"],
  1650. question_index=flow["question_index"],
  1651. )
  1652. await query.message.edit_reply_markup(
  1653. _review_options_markup(question, selected)
  1654. )
  1655. return await query.answer("已更新选择。")
  1656. if action == "multi_done":
  1657. flow = _current_flow(query.from_user.id)
  1658. if not flow or flow["stage"] != "review_question":
  1659. raise ServiceDataError("review_flow_expired", "评价流程已过期。")
  1660. question = flow["template"]["questions"][flow["question_index"]]
  1661. selected = flow["answers"].get(question["question_id"]) or []
  1662. if question.get("required") and not selected:
  1663. return await query.answer("请至少选择一项。", show_alert=True)
  1664. flow["question_index"] += 1
  1665. await save_review_draft_progress(
  1666. query.from_user.id,
  1667. answers=flow["answers"],
  1668. question_index=flow["question_index"],
  1669. )
  1670. await query.answer("已完成选择。")
  1671. return await _ask_review_question(query)
  1672. if action == "anonymous":
  1673. await _submit_pending_review(query, parts[2] == "1")
  1674. return await query.answer("评价已提交审核。")
  1675. if action == "top":
  1676. return await _show_top(query, parts[2], int(parts[3]))
  1677. if action == "profile_template":
  1678. await _start_profile_flow(query, parts[2])
  1679. return await query.answer()
  1680. if action == "profile_template_use":
  1681. flow = _current_flow(query.from_user.id)
  1682. if not flow or flow["stage"] != "template_preview":
  1683. raise ServiceDataError("profile_flow_expired", "套餐配置流程已过期。")
  1684. if flow.get("profile_mode") == "append":
  1685. await _publish_profile(query, flow)
  1686. return await query.answer("套餐已新增。")
  1687. flow["stage"] = "profile_headline"
  1688. await query.message.reply_text("服务资料第 1/7 步:请输入一句服务介绍(最多 80 字)。")
  1689. return await query.answer()
  1690. if action == "profile_edit":
  1691. flow = _current_flow(query.from_user.id)
  1692. if not flow or flow["stage"] != "template_preview":
  1693. raise ServiceDataError("profile_flow_expired", "套餐配置流程已过期。")
  1694. field = parts[2]
  1695. prompts = {
  1696. "name": "请输入新的套餐名称。",
  1697. "category": "请输入新的服务分类。",
  1698. "description": "请输入新的套餐完整说明。",
  1699. "tags": "请输入套餐标签,多个用逗号分隔。",
  1700. "modes": "请输入“到店”、“上门”或“两者”。",
  1701. "price": "输入“价格模式|最低价|最高价”,例如 固定价|200|200。",
  1702. "billing": "输入“ISO币种|计价单位”,例如 CNY|每次。",
  1703. "duration": "输入“时长分钟|上门半径公里”,例如 60|20。",
  1704. "details": "输入“包含项|不包含项|准备事项|超区说明|交通模式|固定金额|每公里金额”。",
  1705. "addons": "输入“名称|单位|价格|额外分钟”,多个用分号分隔;输入“清空”可移除全部。",
  1706. }
  1707. if field not in prompts:
  1708. raise ServiceDataError("invalid_profile_field", "不支持该套餐字段。")
  1709. flow["stage"] = f"template_edit_{field}"
  1710. await query.message.reply_text(prompts[field])
  1711. return await query.answer()
  1712. if action == "profile_custom":
  1713. await _start_custom_package_flow(query)
  1714. return await query.answer()
  1715. if action == "profile_add":
  1716. profile = await get_technician_service_profile(query.from_user.id)
  1717. if len((profile.get("service_profile") or {}).get("packages", [])) >= 5:
  1718. raise ServiceDataError("invalid_package_count", "每位技师最多发布 5 个套餐。")
  1719. _flow(query.from_user.id, "profile_source", profile_mode="append")
  1720. await _show_profile_sources(query)
  1721. return await query.answer()
  1722. if action == "profile_reconfigure":
  1723. _flow(query.from_user.id, "profile_source", profile_mode="replace")
  1724. await _show_profile_sources(query)
  1725. return await query.answer()
  1726. if action == "profile_toggle":
  1727. profile = await get_technician_service_profile(query.from_user.id)
  1728. values = dict(profile["service_profile"])
  1729. values["accepting_requests"] = not values.get("accepting_requests", True)
  1730. await publish_technician_profile(
  1731. query.from_user.id, values, actor_id=query.from_user.id
  1732. )
  1733. await query.answer("接单状态已更新。")
  1734. return await _show_profile(query)
  1735. except (ServiceDataError, ValueError, IndexError) as exc:
  1736. return await query.answer(str(exc), show_alert=True)
  1737. except Exception as exc:
  1738. log.error(f"技师服务回调失败:{type(exc).__name__}")
  1739. return await query.answer("操作失败,请稍后重试。", show_alert=True)
  1740. start_address_redaction_sweeper()