test_money_budget.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. import asyncio
  2. from datetime import timedelta
  3. from uuid import UUID, uuid4
  4. import pytest
  5. from sqlalchemy import func, select
  6. from test_after_sales import scenario # noqa: F401
  7. from test_api_documentation import validate_response
  8. from test_token_budget import sample
  9. from zhifu.core.errors import BusinessError
  10. from zhifu.modules.after_sales.models import BusinessEvent
  11. from zhifu.modules.agent_runtime.budget import change_entry, reserve
  12. from zhifu.modules.agent_runtime.models import TokenEntry
  13. from zhifu.modules.billing.models import BillingChange, MoneyAccount, MoneyEntry
  14. from zhifu.modules.identity.access_models import Principal
  15. from zhifu.modules.identity.auth import now
  16. from zhifu.operations.billing_access import change as access
  17. pytestmark = pytest.mark.integration
  18. PREFIX = "/admin/v1/billing"
  19. PROVIDER = "https://api.deepseek.com"
  20. # 此价格仅用于验证整数运算和事务,不表示供应商报价,不产生供应商消费。
  21. PRICE = {
  22. "kind": "PRICE",
  23. "expected_version": 0,
  24. "provider": PROVIDER,
  25. "model": "deepseek-flash",
  26. "input_rate": 2_000_000,
  27. "cached_rate": 500_000,
  28. "output_rate": 4_000_000,
  29. "source": "事务验收专用价格,2026-09-20;不作为供应商账单依据",
  30. "reason": "验证价格审批与整数金额计算",
  31. }
  32. async def setup(context):
  33. for actor in ("agent", "reviewer"):
  34. for permission in ("billing:read", "billing:request", "billing:approve"):
  35. await access(
  36. context["app"].state.sessions,
  37. context["tenant"].bytes,
  38. context["accounts"][actor].bytes,
  39. permission,
  40. True,
  41. "integration",
  42. "费用事务验收授权",
  43. )
  44. async def submit(context, payload):
  45. path = f"{PREFIX}/requests/{uuid4()}"
  46. response = await context["call"]("PUT", path, "agent", payload)
  47. assert response.status_code == 200, response.text
  48. return path
  49. async def approve(context, payload):
  50. path = await submit(context, payload)
  51. response = await context["call"](
  52. "POST",
  53. path + "/decisions",
  54. "reviewer",
  55. {"expected_version": 1, "approve": True, "reason": "核对完成"},
  56. )
  57. assert response.status_code == 200, response.text
  58. return response.json()
  59. async def budget(context, amount, version=0):
  60. return await approve(
  61. context,
  62. {
  63. "kind": "BUDGET",
  64. "expected_version": version,
  65. "daily_limit": amount,
  66. "reason": "费用预算事务验收",
  67. },
  68. )
  69. async def report(context, suffix="account"):
  70. response = await context["call"]("GET", PREFIX + "/" + suffix, "agent")
  71. assert response.status_code == 200, response.text
  72. return response.json()
  73. async def reserve_sample(context, identifier, tokens=5776, provider=PROVIDER):
  74. await reserve(
  75. context["app"].state.sessions,
  76. context["tenant"].bytes,
  77. identifier,
  78. "agent",
  79. uuid4().bytes,
  80. sample()["model"],
  81. tokens,
  82. provider=provider,
  83. )
  84. async def test_permissions_idempotency_and_documented_responses(scenario): # noqa: F811
  85. call = scenario["call"]
  86. assert (await call("GET", PREFIX + "/account")).status_code == 403
  87. assert (await scenario["client"].get(PREFIX + "/account")).status_code == 401
  88. await setup(scenario)
  89. path = await submit(scenario, PRICE)
  90. assert (await call("PUT", path, "agent", PRICE)).status_code == 200
  91. assert (await call("PUT", path, "agent", {**PRICE, "input_rate": 3})).status_code == 409
  92. decision = {"expected_version": 1, "approve": True, "reason": "核对完成"}
  93. assert (await call("POST", path + "/decisions", "agent", decision)).status_code == 403
  94. results = await asyncio.gather(
  95. *(call("POST", path + "/decisions", "reviewer", decision) for _ in range(4))
  96. )
  97. assert all(response.status_code == 200 for response in results)
  98. async with scenario["app"].state.sessions() as session:
  99. count = await session.scalar(
  100. select(func.count())
  101. .select_from(BusinessEvent)
  102. .where(
  103. BusinessEvent.aggregate_id == UUID(path.rsplit("/", 1)[1]).bytes,
  104. BusinessEvent.event_type == "billing.approved",
  105. )
  106. )
  107. assert count == 1
  108. # 外租户员工即使拥有费用权限,也不能使用当前租户的申请标识。
  109. async with scenario["app"].state.sessions.begin() as session:
  110. foreign = await session.get(Principal, scenario["accounts"]["foreign"].bytes)
  111. foreign.kind = "STAFF"
  112. foreign_tenant = foreign.tenant_id
  113. await access(
  114. scenario["app"].state.sessions,
  115. foreign_tenant,
  116. scenario["accounts"]["foreign"].bytes,
  117. "billing:request",
  118. True,
  119. "integration",
  120. "验证租户隔离",
  121. )
  122. assert (await call("PUT", path, "foreign", PRICE)).status_code == 404
  123. for suffix in (
  124. "account",
  125. "entries",
  126. "requests",
  127. "price?provider=https://api.deepseek.com&model=deepseek-flash",
  128. ):
  129. response = await call("GET", PREFIX + "/" + suffix, "agent")
  130. validate_response(
  131. scenario["app"].openapi(), PREFIX + "/" + suffix.split("?")[0], "get", response, 200
  132. )
  133. async def test_atomic_admission_price_snapshot_and_settlement(scenario): # noqa: F811
  134. await setup(scenario)
  135. await budget(scenario, 46_208)
  136. with pytest.raises(BusinessError) as error:
  137. await reserve_sample(scenario, uuid4().bytes)
  138. assert error.value.code == "BILLING_PRICE_MISSING"
  139. await approve(scenario, PRICE)
  140. identifiers = [uuid4().bytes for _ in range(8)]
  141. async with asyncio.timeout(30):
  142. results = await asyncio.gather(
  143. *(reserve_sample(scenario, identifier) for identifier in identifiers),
  144. return_exceptions=True,
  145. )
  146. assert sum(result is None for result in results) == 2
  147. assert all(
  148. result is None
  149. or (isinstance(result, BusinessError) and result.code == "MONEY_BUDGET_EXCEEDED")
  150. for result in results
  151. )
  152. accepted = [
  153. identifier
  154. for identifier, result in zip(identifiers, results, strict=True)
  155. if result is None
  156. ]
  157. await approve(scenario, {**PRICE, "expected_version": 1, "output_rate": 8_000_000})
  158. sessions, tenant = scenario["app"].state.sessions, scenario["tenant"].bytes
  159. await asyncio.gather(
  160. *(change_entry(sessions, tenant, accepted[0], usage=sample()["usage"]) for _ in range(5))
  161. )
  162. account = await report(scenario)
  163. assert account["consumed"] == 912 and account["reserved"] == 23_104
  164. rows = (await report(scenario, "entries"))["items"]
  165. assert len(rows) == 2 and all(row["price"]["version"] == 1 for row in rows)
  166. async with sessions() as session:
  167. assert (
  168. await session.scalar(
  169. select(func.count()).select_from(TokenEntry).where(TokenEntry.tenant_id == tenant)
  170. )
  171. == 2
  172. )
  173. async def test_unknown_reconciliation_and_late_usage(scenario): # noqa: F811
  174. await setup(scenario)
  175. await approve(scenario, PRICE)
  176. await budget(scenario, 100_000)
  177. identifier = uuid4().bytes
  178. await reserve_sample(scenario, identifier)
  179. sessions, tenant = scenario["app"].state.sessions, scenario["tenant"].bytes
  180. await change_entry(sessions, tenant, identifier)
  181. assert (await report(scenario))["reserved"] == 23_104
  182. await change_entry(
  183. sessions,
  184. tenant,
  185. identifier,
  186. release_reason="供应商未计费记录",
  187. operator="integration",
  188. expected_version=2,
  189. )
  190. assert (await report(scenario))["reserved"] == 23_104
  191. entry = (await report(scenario, "entries"))["items"][0]
  192. await approve(
  193. scenario,
  194. {
  195. "kind": "RECONCILE",
  196. "entry_id": str(UUID(bytes=identifier)),
  197. "expected_version": entry["version"],
  198. "amount": 0,
  199. "reason": "供应商核对记录:确认未计费",
  200. },
  201. )
  202. assert (await report(scenario))["reserved"] == 0
  203. await change_entry(sessions, tenant, identifier, usage=sample()["usage"])
  204. assert (await report(scenario))["consumed"] == 0
  205. async with sessions() as session:
  206. discrepancy = await session.scalar(
  207. select(BusinessEvent).where(
  208. BusinessEvent.aggregate_id == identifier,
  209. BusinessEvent.event_type == "billing.usage_discrepancy",
  210. )
  211. )
  212. assert discrepancy.payload == {"calculated": 912, "reconciled": 0}
  213. # 核实费用优先于迟到回调,再次修正仍需双人审批及账本版本。
  214. entry = (await report(scenario, "entries"))["items"][0]
  215. await approve(
  216. scenario,
  217. {
  218. "kind": "RECONCILE",
  219. "entry_id": str(UUID(bytes=identifier)),
  220. "expected_version": entry["version"],
  221. "amount": 912,
  222. "reason": "供应商最终账单确认记录",
  223. },
  224. )
  225. assert (await report(scenario))["consumed"] == 912
  226. async def test_expiry_revocation_and_concurrent_policy_changes(scenario): # noqa: F811
  227. await setup(scenario)
  228. body = {"kind": "BUDGET", "expected_version": 0, "daily_limit": 100, "reason": "预算核对"}
  229. paths = [await submit(scenario, body) for _ in range(2)]
  230. decision = {"expected_version": 1, "approve": True, "reason": "核对完成"}
  231. results = await asyncio.gather(
  232. *(scenario["call"]("POST", path + "/decisions", "reviewer", decision) for path in paths)
  233. )
  234. assert sorted(response.status_code for response in results) == [200, 409]
  235. expired = await submit(scenario, {**PRICE})
  236. async with scenario["app"].state.sessions.begin() as session:
  237. row = await session.get(BillingChange, UUID(expired.rsplit("/", 1)[1]).bytes)
  238. row.expires_at = now() - timedelta(seconds=1)
  239. response = await scenario["call"]("POST", expired + "/decisions", "reviewer", decision)
  240. assert response.json()["code"] == "BILLING_REQUEST_EXPIRED"
  241. pending = await submit(scenario, PRICE)
  242. await access(
  243. scenario["app"].state.sessions,
  244. scenario["tenant"].bytes,
  245. scenario["accounts"]["agent"].bytes,
  246. "billing:request",
  247. False,
  248. "integration",
  249. "撤销申请权限",
  250. )
  251. assert (
  252. await scenario["call"]("POST", pending + "/decisions", "reviewer", decision)
  253. ).status_code == 403
  254. async def test_cross_day_account_and_zero_budget(scenario): # noqa: F811
  255. await setup(scenario)
  256. await approve(scenario, PRICE)
  257. await budget(scenario, 100_000)
  258. identifier = uuid4().bytes
  259. await reserve_sample(scenario, identifier)
  260. sessions, tenant = scenario["app"].state.sessions, scenario["tenant"].bytes
  261. day = now().date() - timedelta(days=1)
  262. async with sessions.begin() as session:
  263. session.add(
  264. MoneyAccount(
  265. tenant_id=tenant, period=day, daily_limit=100_000, reserved=23_104, consumed=0
  266. )
  267. )
  268. await session.flush()
  269. row = await session.get(MoneyEntry, identifier)
  270. row.period = day
  271. account = await session.get(MoneyAccount, (tenant, now().date()))
  272. account.reserved = 0
  273. await budget(scenario, 0, 1)
  274. await change_entry(sessions, tenant, identifier, usage=sample()["usage"])
  275. assert (await report(scenario))["consumed"] == 0
  276. assert (await report(scenario, "account?period=" + day.isoformat()))["consumed"] == 912
  277. with pytest.raises(BusinessError) as error:
  278. await reserve_sample(scenario, uuid4().bytes)
  279. assert error.value.code == "MONEY_BUDGET_EXCEEDED"
  280. async def test_missing_cache_usage_requires_reconciliation(scenario): # noqa: F811
  281. await setup(scenario)
  282. await approve(scenario, PRICE)
  283. await budget(scenario, 100_000)
  284. identifier = uuid4().bytes
  285. await reserve_sample(scenario, identifier)
  286. # 从已有 usage 移除可选缓存明细,验证证据不足时不会推算费用。
  287. usage = {key: value for key, value in sample()["usage"].items() if key != "input_token_details"}
  288. await change_entry(
  289. scenario["app"].state.sessions, scenario["tenant"].bytes, identifier, usage=usage
  290. )
  291. account = await report(scenario)
  292. assert account["consumed"] == 0 and account["reserved"] == 23_104
  293. entry = (await report(scenario, "entries"))["items"][0]
  294. assert entry["status"] == "UNKNOWN" and entry["charged"] is None
  295. async def test_rejection_validation_and_cursor(scenario): # noqa: F811
  296. await setup(scenario)
  297. call = scenario["call"]
  298. for bad in ({**PRICE, "input_rate": -1}, {**PRICE, "output_rate": 0.5}):
  299. response = await call("PUT", f"{PREFIX}/requests/{uuid4()}", "agent", bad)
  300. assert response.status_code == 422
  301. path = await submit(scenario, PRICE)
  302. decision = {"expected_version": 1, "approve": False, "reason": "需要重新核实价格"}
  303. response = await call("POST", path + "/decisions", "reviewer", decision)
  304. assert response.status_code == 200 and response.json()["status"] == "REJECTED"
  305. assert (await call("POST", path + "/decisions", "reviewer", decision)).status_code == 200
  306. response = await call("POST", path + "/decisions", "reviewer", {**decision, "approve": True})
  307. assert response.status_code == 409
  308. await submit(scenario, PRICE)
  309. first = await report(scenario, "requests?limit=1")
  310. second = await report(scenario, "requests?limit=1&after=" + first["next_cursor"])
  311. assert first["items"][0]["id"] != second["items"][0]["id"]
  312. assert second["next_cursor"] is None
  313. async def test_fractional_micro_cny_rounding_and_downward_adjustment(scenario): # noqa: F811
  314. await setup(scenario)
  315. await approve(scenario, {**PRICE, "input_rate": 1, "cached_rate": 1, "output_rate": 1})
  316. await budget(scenario, 100)
  317. identifier = uuid4().bytes
  318. await reserve_sample(scenario, identifier)
  319. await change_entry(
  320. scenario["app"].state.sessions,
  321. scenario["tenant"].bytes,
  322. identifier,
  323. usage=sample()["usage"],
  324. )
  325. assert (await report(scenario))["consumed"] == 1
  326. entry = (await report(scenario, "entries"))["items"][0]
  327. await approve(
  328. scenario,
  329. {
  330. "kind": "RECONCILE",
  331. "entry_id": str(UUID(bytes=identifier)),
  332. "expected_version": entry["version"],
  333. "amount": 0,
  334. "reason": "供应商确认本次费用减免",
  335. },
  336. )
  337. account = await report(scenario)
  338. assert account["consumed"] == 0 and account["reserved"] == 0
  339. assert account["available"] == 100