service.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. import hmac
  2. import json
  3. from collections.abc import Callable
  4. from dataclasses import replace
  5. from datetime import datetime, timedelta
  6. from hashlib import sha256
  7. from secrets import token_urlsafe
  8. from typing import Any, Protocol
  9. from app.core.errors import AppError
  10. from app.core.identifiers import new_ulid
  11. from app.domains.catalog.service import ProductCatalogService
  12. from app.domains.enrollment.models import (
  13. AsyncTask,
  14. EnrollmentDraft,
  15. EnrollmentOrder,
  16. OutboxEvent,
  17. PaymentTransaction,
  18. Policy,
  19. Quote,
  20. UserConfirmation,
  21. )
  22. from app.domains.enrollment.repository import EnrollmentRepository
  23. from app.domains.enrollment.tasks import InlinePolicyTaskDispatcher, TaskDispatcher
  24. from app.domains.identity.models import H5User
  25. class OrderAttributionRecorder(Protocol):
  26. def record_order(self, order: EnrollmentOrder) -> None: ...
  27. class EnrollmentService:
  28. def __init__(
  29. self,
  30. repository: EnrollmentRepository,
  31. catalog_service: ProductCatalogService,
  32. clock: Callable[[], datetime],
  33. callback_secret: str,
  34. attribution_recorder: OrderAttributionRecorder | None = None,
  35. task_dispatcher: TaskDispatcher | None = None,
  36. ) -> None:
  37. self._repository = repository
  38. self._catalog = catalog_service
  39. self._clock = clock
  40. self._callback_secret = callback_secret.encode("utf-8")
  41. self._attribution_recorder = attribution_recorder
  42. self._task_dispatcher = task_dispatcher or InlinePolicyTaskDispatcher(
  43. repository,
  44. clock,
  45. )
  46. def create_quote(
  47. self,
  48. user: H5User,
  49. *,
  50. product_id: str,
  51. plan_id: str,
  52. age: int,
  53. region_code: str,
  54. occupation_code: str,
  55. relationship: str,
  56. ) -> dict[str, Any]:
  57. product = next(
  58. (item for item in self._catalog.list_available() if item["product_id"] == product_id),
  59. None,
  60. )
  61. if product is None:
  62. raise AppError("PRODUCT_NOT_AVAILABLE", "产品当前不可投保", 404)
  63. plan = next(
  64. (item for item in product["plans"] if item["id"] == plan_id),
  65. None,
  66. )
  67. if plan is None:
  68. raise AppError("PLAN_NOT_AVAILABLE", "保障计划当前不可投保", 404)
  69. premium = self._deterministic_premium(
  70. str(product["product_code"]),
  71. str(plan["code"]),
  72. age,
  73. region_code,
  74. occupation_code,
  75. configured_premium=int(plan.get("premium_cents", 0)),
  76. configured_min_age=int(plan.get("min_age", 0)),
  77. configured_max_age=int(plan.get("max_age", 100)),
  78. )
  79. now = self._clock()
  80. quote = Quote(
  81. id=new_ulid(),
  82. user_id=user.id,
  83. product_id=product_id,
  84. product_version_id=str(product["product_version_id"]),
  85. plan_id=plan_id,
  86. insured_age=age,
  87. insured_region_code=region_code,
  88. occupation_code=occupation_code,
  89. relationship=relationship,
  90. premium_cents=premium,
  91. currency="CNY",
  92. rule_version="eligibility-v1",
  93. rate_version="rate-v1",
  94. status="VALID",
  95. expires_at=now + timedelta(minutes=30),
  96. created_at=now,
  97. )
  98. self._repository.save_quote(quote)
  99. return {
  100. "eligible": True,
  101. "eligibility_reasons": [],
  102. "quote_id": quote.id,
  103. "product_id": quote.product_id,
  104. "product_version_id": quote.product_version_id,
  105. "plan_id": quote.plan_id,
  106. "premium_cents": quote.premium_cents,
  107. "currency": quote.currency,
  108. "rule_version": quote.rule_version,
  109. "rate_version": quote.rate_version,
  110. "expires_at": quote.expires_at,
  111. }
  112. def create_draft(
  113. self,
  114. user: H5User,
  115. *,
  116. quote_id: str,
  117. applicant: dict[str, Any],
  118. insured: dict[str, Any],
  119. contact: dict[str, Any],
  120. ) -> dict[str, Any]:
  121. quote = self._repository.get_quote(quote_id)
  122. now = self._clock()
  123. if (
  124. quote is None
  125. or quote.user_id != user.id
  126. or quote.status != "VALID"
  127. or quote.expires_at <= now
  128. ):
  129. raise AppError("QUOTE_NOT_AVAILABLE", "报价不存在或已失效", 410)
  130. draft = EnrollmentDraft(
  131. id=new_ulid(),
  132. user_id=user.id,
  133. quote_id=quote.id,
  134. applicant=applicant,
  135. insured=insured,
  136. contact=contact,
  137. status="EDITABLE",
  138. expires_at=now + timedelta(hours=24),
  139. created_at=now,
  140. )
  141. self._repository.save_draft(draft)
  142. return {
  143. "draft_id": draft.id,
  144. "quote_id": draft.quote_id,
  145. "status": draft.status,
  146. "expires_at": draft.expires_at,
  147. }
  148. def confirm_draft(self, user: H5User, draft_id: str) -> dict[str, Any]:
  149. draft = self._repository.get_draft(draft_id)
  150. now = self._clock()
  151. if (
  152. draft is None
  153. or draft.user_id != user.id
  154. or draft.status != "EDITABLE"
  155. or draft.expires_at <= now
  156. ):
  157. raise AppError("DRAFT_NOT_AVAILABLE", "投保草稿不存在或不可确认", 409)
  158. raw_token = token_urlsafe(32)
  159. confirmation = UserConfirmation(
  160. id=new_ulid(),
  161. user_id=user.id,
  162. draft_id=draft.id,
  163. token_hash=self._hash_token(raw_token),
  164. status="PENDING",
  165. expires_at=now + timedelta(minutes=10),
  166. created_at=now,
  167. )
  168. self._repository.save_confirmation(confirmation)
  169. return {
  170. "confirmation_id": confirmation.id,
  171. "confirmation_token": raw_token,
  172. "expires_at": confirmation.expires_at,
  173. }
  174. def create_order(
  175. self,
  176. user: H5User,
  177. *,
  178. draft_id: str,
  179. confirmation_token: str,
  180. idempotency_key: str,
  181. ) -> tuple[dict[str, Any], bool]:
  182. existing = self._repository.find_order_by_idempotency(user.id, idempotency_key)
  183. if existing is not None:
  184. if existing.draft_id != draft_id:
  185. raise AppError(
  186. "IDEMPOTENCY_CONFLICT",
  187. "相同幂等键已用于其他投保请求",
  188. 409,
  189. )
  190. return self._order_data(existing), False
  191. now = self._clock()
  192. draft = self._repository.get_draft(draft_id)
  193. confirmation = self._repository.get_confirmation_by_token_hash(
  194. self._hash_token(confirmation_token)
  195. )
  196. if draft is None or draft.user_id != user.id:
  197. raise AppError("DRAFT_NOT_AVAILABLE", "投保草稿不存在", 404)
  198. if (
  199. confirmation is None
  200. or confirmation.user_id != user.id
  201. or confirmation.draft_id != draft.id
  202. or confirmation.status != "PENDING"
  203. or confirmation.expires_at <= now
  204. ):
  205. raise AppError(
  206. "CONFIRMATION_REQUIRED",
  207. "创建订单前需要有效的用户确认",
  208. 409,
  209. )
  210. quote = self._repository.get_quote(draft.quote_id)
  211. if quote is None or quote.expires_at <= now:
  212. raise AppError("QUOTE_EXPIRED", "报价已过期", 410)
  213. order = EnrollmentOrder(
  214. id=new_ulid(),
  215. order_no=f"ORD-{now:%Y%m%d}-{new_ulid()[-8:]}",
  216. user_id=user.id,
  217. quote_id=quote.id,
  218. draft_id=draft.id,
  219. confirmation_id=confirmation.id,
  220. idempotency_key=idempotency_key,
  221. product_id=quote.product_id,
  222. product_version_id=quote.product_version_id,
  223. plan_id=quote.plan_id,
  224. applicant_snapshot=draft.applicant,
  225. insured_snapshot=draft.insured,
  226. amount_cents=quote.premium_cents,
  227. currency=quote.currency,
  228. status="PENDING_PAYMENT",
  229. created_at=now,
  230. )
  231. self._repository.save_order(order)
  232. if self._attribution_recorder is not None:
  233. self._attribution_recorder.record_order(order)
  234. return self._order_data(order), True
  235. def create_payment(
  236. self,
  237. user: H5User,
  238. *,
  239. order_id: str,
  240. idempotency_key: str,
  241. ) -> tuple[dict[str, Any], bool]:
  242. order = self._repository.get_order(order_id)
  243. if order is None or order.user_id != user.id:
  244. raise AppError("ORDER_NOT_FOUND", "未找到订单", 404)
  245. if order.status != "PENDING_PAYMENT":
  246. raise AppError("ORDER_NOT_PAYABLE", "订单当前不可支付", 409)
  247. existing = self._repository.find_payment_by_idempotency(order.id, idempotency_key)
  248. if existing is not None:
  249. return self._payment_data(existing), False
  250. now = self._clock()
  251. payment = PaymentTransaction(
  252. id=new_ulid(),
  253. payment_no=f"PAY-{now:%Y%m%d}-{new_ulid()[-8:]}",
  254. order_id=order.id,
  255. user_id=user.id,
  256. transaction_type="PAYMENT",
  257. provider="LOCAL_MOCK_PAY",
  258. idempotency_key=idempotency_key,
  259. amount_cents=order.amount_cents,
  260. currency=order.currency,
  261. status="CREATED",
  262. provider_transaction_no=None,
  263. succeeded_at=None,
  264. created_at=now,
  265. )
  266. self._repository.save_payment(payment)
  267. return self._payment_data(payment), True
  268. def complete_mock_payment(
  269. self,
  270. user: H5User,
  271. *,
  272. payment_id: str,
  273. ) -> dict[str, Any]:
  274. payment = self._repository.get_payment(payment_id)
  275. if payment is None or payment.user_id != user.id:
  276. raise AppError("PAYMENT_NOT_FOUND", "未找到支付流水", 404)
  277. now = self._clock()
  278. payload = {
  279. "callback_no": f"CB-{now:%Y%m%d}-{payment.id[-8:]}",
  280. "provider_transaction_no": f"MP-{now:%Y%m%d}-{payment.id[-8:]}",
  281. "payment_no": payment.payment_no,
  282. "status": "SUCCEEDED",
  283. "amount_cents": payment.amount_cents,
  284. "occurred_at": now.isoformat(),
  285. }
  286. signature = self.sign_mock_callback(payload)
  287. return self.handle_mock_callback(payload, signature)
  288. def handle_mock_callback(
  289. self,
  290. payload: dict[str, Any],
  291. signature: str,
  292. ) -> dict[str, Any]:
  293. expected_signature = self.sign_mock_callback(payload)
  294. if not hmac.compare_digest(signature, expected_signature):
  295. raise AppError("INVALID_CALLBACK_SIGNATURE", "支付回调签名无效", 401)
  296. payment = self._repository.get_payment_by_no(str(payload["payment_no"]))
  297. if payment is None:
  298. raise AppError("PAYMENT_NOT_FOUND", "未找到支付流水", 404)
  299. if payload["status"] != "SUCCEEDED":
  300. raise AppError("PAYMENT_CALLBACK_REJECTED", "支付结果不是成功状态", 409)
  301. if int(payload["amount_cents"]) != payment.amount_cents:
  302. raise AppError("PAYMENT_AMOUNT_MISMATCH", "支付回调金额不一致", 409)
  303. order = self._repository.get_order(payment.order_id)
  304. if order is None:
  305. raise AppError("ORDER_NOT_FOUND", "未找到支付对应订单", 404)
  306. existing_policy = self._repository.get_policy_by_order(order.id)
  307. if payment.status == "SUCCEEDED" and existing_policy is not None:
  308. return self._payment_completion_data(payment, order, existing_policy)
  309. now = self._clock()
  310. succeeded_payment = replace(
  311. payment,
  312. status="SUCCEEDED",
  313. provider_transaction_no=str(payload["provider_transaction_no"]),
  314. succeeded_at=now,
  315. )
  316. paid_order = replace(order, status="PAID")
  317. self._repository.save_payment(succeeded_payment)
  318. self._repository.save_order(paid_order)
  319. event = OutboxEvent(
  320. id=new_ulid(),
  321. aggregate_type="PAYMENT",
  322. aggregate_id=payment.id,
  323. event_type="payment.succeeded",
  324. payload={"payment_id": payment.id, "order_id": order.id},
  325. status="PENDING",
  326. created_at=now,
  327. )
  328. task_key = f"policy.issue:{order.id}"
  329. task = self._repository.get_task_by_idempotency(task_key) or AsyncTask(
  330. id=new_ulid(),
  331. task_type="policy.issue",
  332. business_key=order.id,
  333. idempotency_key=task_key,
  334. payload={"order_id": order.id},
  335. status="PENDING",
  336. attempt_count=0,
  337. created_at=now,
  338. )
  339. self._repository.save_outbox_event(event)
  340. self._repository.save_task(task)
  341. policy = existing_policy or self._task_dispatcher.dispatch(task)
  342. self._repository.save_outbox_event(replace(event, status="DISPATCHED"))
  343. issued_order = self._repository.get_order(order.id)
  344. if issued_order is None:
  345. raise AppError("ORDER_NOT_FOUND", "保单签发后未找到订单", 500)
  346. return self._payment_completion_data(succeeded_payment, issued_order, policy)
  347. def sign_mock_callback(self, payload: dict[str, Any]) -> str:
  348. canonical = json.dumps(
  349. payload,
  350. ensure_ascii=False,
  351. sort_keys=True,
  352. separators=(",", ":"),
  353. ).encode("utf-8")
  354. return hmac.new(
  355. self._callback_secret,
  356. canonical,
  357. digestmod=sha256,
  358. ).hexdigest()
  359. def list_policies(self, user: H5User) -> dict[str, Any]:
  360. policies = self._repository.list_policies(user.id)
  361. items = [self._policy_data(policy) for policy in policies]
  362. return {"items": items, "total": len(items)}
  363. def list_all_policies(self) -> dict[str, Any]:
  364. policies = self._repository.list_policies()
  365. items = [self._policy_data(policy) for policy in policies]
  366. return {"items": items, "total": len(items)}
  367. def list_orders(self, user_id: str | None = None) -> dict[str, Any]:
  368. orders = self._repository.list_orders(user_id)
  369. items = [self._order_data(order) for order in orders]
  370. return {"items": items, "total": len(items)}
  371. @staticmethod
  372. def _hash_token(token: str) -> str:
  373. return sha256(token.encode("utf-8")).hexdigest()
  374. def _order_data(self, order: EnrollmentOrder) -> dict[str, Any]:
  375. draft = self._repository.get_draft(order.draft_id)
  376. quote = self._repository.get_quote(order.quote_id)
  377. payment = self._repository.get_payment_by_order(order.id)
  378. policy = self._repository.get_policy_by_order(order.id)
  379. product = next(
  380. (
  381. item
  382. for item in self._catalog.list_available()
  383. if item["product_id"] == order.product_id
  384. ),
  385. None,
  386. )
  387. plan = (
  388. next(
  389. (item for item in product["plans"] if item["id"] == order.plan_id),
  390. None,
  391. )
  392. if product is not None
  393. else None
  394. )
  395. return {
  396. "order_id": order.id,
  397. "order_no": order.order_no,
  398. "user_id": order.user_id,
  399. "product_name": product["name"] if product else "智保通保障计划",
  400. "plan_name": plan["name"] if plan else "保障计划",
  401. "relationship": quote.relationship if quote else "SELF",
  402. "applicant": order.applicant_snapshot,
  403. "insured": order.insured_snapshot,
  404. "contact": draft.contact if draft else {},
  405. "status": order.status,
  406. "amount_cents": order.amount_cents,
  407. "currency": order.currency,
  408. "payment": (
  409. {
  410. "payment_id": payment.id,
  411. "payment_no": payment.payment_no,
  412. "provider": payment.provider,
  413. "status": payment.status,
  414. "provider_transaction_no": payment.provider_transaction_no,
  415. "succeeded_at": payment.succeeded_at,
  416. }
  417. if payment
  418. else None
  419. ),
  420. "policy_no": policy.policy_no if policy else None,
  421. "created_at": order.created_at,
  422. }
  423. @staticmethod
  424. def _payment_data(payment: PaymentTransaction) -> dict[str, Any]:
  425. return {
  426. "payment_id": payment.id,
  427. "payment_no": payment.payment_no,
  428. "order_id": payment.order_id,
  429. "transaction_type": payment.transaction_type,
  430. "status": payment.status,
  431. "amount_cents": payment.amount_cents,
  432. "currency": payment.currency,
  433. }
  434. def _policy_data(self, policy: Policy) -> dict[str, Any]:
  435. order = self._repository.get_order(policy.order_id)
  436. quote = self._repository.get_quote(order.quote_id) if order else None
  437. product = next(
  438. (
  439. item
  440. for item in self._catalog.list_available()
  441. if item["product_id"] == policy.product_id
  442. ),
  443. None,
  444. )
  445. plan = (
  446. next(
  447. (item for item in product["plans"] if item["id"] == policy.plan_id),
  448. None,
  449. )
  450. if product is not None
  451. else None
  452. )
  453. return {
  454. "policy_id": policy.id,
  455. "policy_no": policy.policy_no,
  456. "order_id": policy.order_id,
  457. "order_no": order.order_no if order else "",
  458. "product_name": product["name"] if product else "智保通保障计划",
  459. "plan_name": plan["name"] if plan else "保障计划",
  460. "relationship": quote.relationship if quote else "SELF",
  461. "applicant": order.applicant_snapshot if order else {},
  462. "insured": order.insured_snapshot if order else {},
  463. "status": policy.status,
  464. "premium_cents": policy.premium_cents,
  465. "currency": policy.currency,
  466. "coverage_start": policy.coverage_start,
  467. "coverage_end": policy.coverage_end,
  468. "issued_at": policy.issued_at,
  469. }
  470. def _payment_completion_data(
  471. self,
  472. payment: PaymentTransaction,
  473. order: EnrollmentOrder,
  474. policy: Policy,
  475. ) -> dict[str, Any]:
  476. return {
  477. "payment_id": payment.id,
  478. "payment_status": payment.status,
  479. "order_id": order.id,
  480. "order_status": order.status,
  481. "policy": self._policy_data(policy),
  482. }
  483. @staticmethod
  484. def _deterministic_premium(
  485. product_code: str,
  486. plan_code: str,
  487. age: int,
  488. region_code: str,
  489. occupation_code: str,
  490. *,
  491. configured_premium: int = 0,
  492. configured_min_age: int = 0,
  493. configured_max_age: int = 100,
  494. ) -> int:
  495. if region_code != "510100":
  496. raise AppError(
  497. "ELIGIBILITY_REJECTED",
  498. "当前产品仅支持成都地区",
  499. 422,
  500. details={"reason_code": "REGION_NOT_SUPPORTED"},
  501. )
  502. if occupation_code != "GENERAL":
  503. raise AppError(
  504. "ELIGIBILITY_REJECTED",
  505. "当前职业类别不在可投保范围",
  506. 422,
  507. details={"reason_code": "OCCUPATION_NOT_SUPPORTED"},
  508. )
  509. rates: dict[tuple[str, str], tuple[int, int, int]] = {
  510. ("MED-SENIOR", "SENIOR_STANDARD"): (50, 75, 19900),
  511. ("MED-BASIC", "BASIC"): (0, 65, 23900),
  512. ("MED-UPGRADE", "STANDARD"): (0, 65, 39900),
  513. ("MED-UPGRADE", "ENHANCED"): (0, 65, 59900),
  514. ("ACC-FAMILY", "INDIVIDUAL"): (0, 70, 9900),
  515. ("ACC-FAMILY", "FAMILY"): (0, 70, 15900),
  516. }
  517. rule = rates.get((product_code, plan_code))
  518. min_age = configured_min_age if configured_premium > 0 else (rule[0] if rule else 0)
  519. max_age = configured_max_age if configured_premium > 0 else (rule[1] if rule else -1)
  520. if (configured_premium <= 0 and rule is None) or not min_age <= age <= max_age:
  521. raise AppError(
  522. "ELIGIBILITY_REJECTED",
  523. "被保人年龄不在该计划可投保范围",
  524. 422,
  525. details={"reason_code": "AGE_OUT_OF_RANGE"},
  526. )
  527. if configured_premium > 0:
  528. return configured_premium
  529. if rule is None:
  530. raise AppError("RATE_NOT_FOUND", "未找到该保障计划的费率", 422)
  531. return rule[2]