service.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. from datetime import datetime
  2. from decimal import Decimal
  3. from typing import Any
  4. from redis.asyncio import Redis
  5. from app.api.v1.module_system.auth.schema import AuthSchema
  6. from app.core.alipay import AlipayClient
  7. from app.core.exceptions import CustomException
  8. from app.core.logger import log
  9. from app.utils.snowflake import get_snowflake_id_str
  10. from app.plugin.module_payment.enterprise.crud import EnterpriseCRUD
  11. from .crud import AccountCRUD, TransferCRUD, DepositCRUD, WithdrawCRUD
  12. from .enums import (
  13. DepositStatusEnum,
  14. )
  15. from .schema import (
  16. AccountAuthorizeApplySchema,
  17. AccountAuthorizeApplyOutSchema,
  18. AccountCreateSchema,
  19. AccountDepositSchema,
  20. AccountDepositOutSchema,
  21. AccountOperationOutSchema,
  22. AccountQuerySchema,
  23. AccountTransferSchema,
  24. AccountTransferOutSchema,
  25. TransferListOutSchema,
  26. TransferOutSchema,
  27. )
  28. class AccountService:
  29. """资金专户服务层"""
  30. @classmethod
  31. async def authorize_apply_service(
  32. cls,
  33. auth: AuthSchema,
  34. data: AccountAuthorizeApplySchema
  35. ) -> AccountAuthorizeApplyOutSchema:
  36. """
  37. 申请转账授权签约(✅)
  38. 调用: alipay.commerce.ec.trans.authorize.apply
  39. """
  40. from alipay.aop.api.request.AlipayCommerceEcTransAuthorizeApplyRequest import (
  41. AlipayCommerceEcTransAuthorizeApplyRequest,
  42. )
  43. from alipay.aop.api.domain.AlipayCommerceEcTransAuthorizeApplyModel import (
  44. AlipayCommerceEcTransAuthorizeApplyModel,
  45. )
  46. from alipay.aop.api.response.AlipayCommerceEcTransAuthorizeApplyResponse import (
  47. AlipayCommerceEcTransAuthorizeApplyResponse,
  48. )
  49. model = AlipayCommerceEcTransAuthorizeApplyModel()
  50. model.enterprise_id = data.enterprise_id
  51. request = AlipayCommerceEcTransAuthorizeApplyRequest()
  52. request.biz_model = model
  53. client = AlipayClient.get_client()
  54. response = client.execute(request)
  55. if not response:
  56. raise CustomException(msg="申请转账授权失败: 无响应")
  57. result = AlipayCommerceEcTransAuthorizeApplyResponse()
  58. result.parse_response_content(response)
  59. if not result.is_success():
  60. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  61. raise CustomException(msg=f"申请转账授权失败: {result.msg}")
  62. return AccountAuthorizeApplyOutSchema(
  63. sign_url=result.sign_url,
  64. )
  65. @classmethod
  66. async def create_account_service(
  67. cls,
  68. auth: AuthSchema,
  69. data: AccountCreateSchema
  70. ) -> AccountOperationOutSchema:
  71. """
  72. 开通资金专户(✅)
  73. 调用: alipay.commerce.ec.trans.account.create
  74. """
  75. from alipay.aop.api.request.AlipayCommerceEcTransAccountCreateRequest import (
  76. AlipayCommerceEcTransAccountCreateRequest,
  77. )
  78. from alipay.aop.api.domain.AlipayCommerceEcTransAccountCreateModel import (
  79. AlipayCommerceEcTransAccountCreateModel,
  80. )
  81. from alipay.aop.api.response.AlipayCommerceEcTransAccountCreateResponse import (
  82. AlipayCommerceEcTransAccountCreateResponse,
  83. )
  84. model = AlipayCommerceEcTransAccountCreateModel()
  85. model.enterprise_id = data.enterprise_id
  86. model.account_type = data.account_type or "ALL" # 收支全能户
  87. model.scene = data.scene or "B2B_TRANS" # ToB转账场景
  88. request = AlipayCommerceEcTransAccountCreateRequest()
  89. request.biz_model = model
  90. client = AlipayClient.get_client()
  91. response = client.execute(request)
  92. if not response:
  93. raise CustomException(msg="开通资金专户失败: 无响应")
  94. result = AlipayCommerceEcTransAccountCreateResponse()
  95. result.parse_response_content(response)
  96. if not result.is_success():
  97. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  98. raise CustomException(msg=f"开通资金专户失败: {result.msg}")
  99. account_data = AccountCreateSchema(
  100. enterprise_id=model.enterprise_id,
  101. account_book_id=result.account_book_id,
  102. account_type=model.account_type,
  103. scene=model.scene,
  104. )
  105. if result.account_book_id:
  106. account_data.account_book_id = result.account_book_id
  107. await AccountCRUD(auth).create(account_data)
  108. return AccountOperationOutSchema(
  109. enterprise_id=account_data.enterprise_id,
  110. account_book_id=account_data.account_book_id,
  111. )
  112. @classmethod
  113. async def deposit_service(
  114. cls,
  115. auth: AuthSchema,
  116. data: AccountDepositSchema
  117. ) -> AccountDepositOutSchema:
  118. """
  119. 资金专户充值(✅)
  120. 调用: alipay.commerce.ec.trans.account.deposit
  121. """
  122. from alipay.aop.api.request.AlipayCommerceEcTransAccountDepositRequest import (
  123. AlipayCommerceEcTransAccountDepositRequest,
  124. )
  125. from alipay.aop.api.domain.AlipayCommerceEcTransAccountDepositModel import (
  126. AlipayCommerceEcTransAccountDepositModel,
  127. )
  128. from alipay.aop.api.response.AlipayCommerceEcTransAccountDepositResponse import (
  129. AlipayCommerceEcTransAccountDepositResponse,
  130. )
  131. model = AlipayCommerceEcTransAccountDepositModel()
  132. model.enterprise_id = data.enterprise_id
  133. model.account_book_id = data.account_book_id
  134. model.amount = str(data.amount)
  135. model.out_biz_no = get_snowflake_id_str(auth.tenant_id)
  136. request = AlipayCommerceEcTransAccountDepositRequest()
  137. request.biz_model = model
  138. client = AlipayClient.get_client()
  139. response = client.execute(request)
  140. if not response:
  141. raise CustomException(msg="充值失败: 无响应")
  142. result = AlipayCommerceEcTransAccountDepositResponse()
  143. result.parse_response_content(response)
  144. if not result.is_success():
  145. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  146. raise CustomException(msg=f"充值失败: {result.msg}")
  147. deposit_crud = DepositCRUD(auth)
  148. deposit_data = {
  149. "enterprise_id": data.enterprise_id,
  150. "out_biz_no": model.out_biz_no,
  151. "account_book_id": data.account_book_id,
  152. "amount": data.amount,
  153. "url": result.url,
  154. "status": DepositStatusEnum.DEALING.value,
  155. "remark": data.remark,
  156. }
  157. await deposit_crud.create(deposit_data)
  158. return AccountDepositOutSchema(
  159. url=result.url,
  160. )
  161. @classmethod
  162. async def transfer_service(
  163. cls,
  164. auth: AuthSchema,
  165. data: AccountTransferSchema
  166. ) -> AccountTransferOutSchema:
  167. """
  168. 资金专户转账(✅)
  169. 调用: alipay.commerce.ec.trans.account.transfer
  170. """
  171. from alipay.aop.api.request.AlipayCommerceEcTransAccountTransferRequest import (
  172. AlipayCommerceEcTransAccountTransferRequest,
  173. )
  174. from alipay.aop.api.domain.AlipayCommerceEcTransAccountTransferModel import (
  175. AlipayCommerceEcTransAccountTransferModel,
  176. )
  177. from alipay.aop.api.response.AlipayCommerceEcTransAccountTransferResponse import (
  178. AlipayCommerceEcTransAccountTransferResponse,
  179. )
  180. from alipay.aop.api.domain.TransParticipant import (
  181. TransParticipant,
  182. )
  183. from alipay.aop.api.domain.BankCardExtInfoDTO import (
  184. BankCardExtInfoDTO,
  185. )
  186. # 检查企业是否存在
  187. enterprise = await EnterpriseCRUD(auth).get_by_enterprise_id(data.enterprise_id)
  188. if not enterprise:
  189. raise CustomException(msg="企业不存在")
  190. if enterprise.tenant_id != auth.tenant_id:
  191. raise CustomException(msg="无权限操作")
  192. # 检查资金专户是否存在
  193. account = await AccountCRUD(auth).get_by_enterprise_id(data.enterprise_id)
  194. if not account:
  195. raise CustomException(msg="资金账户不存在")
  196. model = AlipayCommerceEcTransAccountTransferModel()
  197. model.enterprise_id = enterprise.enterprise_id
  198. model.account_book_id = account.account_book_id
  199. model.out_biz_no = get_snowflake_id_str(auth.tenant_id)
  200. # 转账总金额,单位为元,精确到小数点后两位
  201. model.amount = str(data.amount)
  202. model.order_title = data.order_title or f"{enterprise.name}转账"
  203. payee_info = TransParticipant()
  204. payee_info.identity_type = data.payee_info.identity_type
  205. payee_info.name = data.payee_info.name
  206. payee_info.identity = data.payee_info.identity
  207. if data.payee_info.bankcard_ext_info:
  208. payee_info.bankcard_ext_info = BankCardExtInfoDTO.from_alipay_dict(
  209. data.payee_info.bankcard_ext_info.model_dump(exclude_none=True)
  210. )
  211. model.payee_info = payee_info
  212. request = AlipayCommerceEcTransAccountTransferRequest()
  213. request.biz_model = model
  214. client = AlipayClient.get_client()
  215. response = client.execute(request)
  216. if not response:
  217. raise CustomException(msg="转账失败: 无响应")
  218. result = AlipayCommerceEcTransAccountTransferResponse()
  219. result.parse_response_content(response)
  220. if not result.is_success():
  221. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  222. raise CustomException(msg=f"转账失败: {result.sub_msg or result.msg or result.code}")
  223. transfer_crud = TransferCRUD(auth)
  224. transfer_data = {
  225. "enterprise_id": model.enterprise_id,
  226. "out_biz_no": model.out_biz_no,
  227. "account_book_id": model.account_book_id,
  228. "amount": model.amount,
  229. "order_title": model.order_title,
  230. "payee_info": data.payee_info.model_dump() if data.payee_info else None,
  231. "status": result.status,
  232. "order_no": result.order_no,
  233. "fund_order_id": result.fund_order_id,
  234. }
  235. await transfer_crud.create(transfer_data)
  236. return AccountTransferOutSchema(
  237. status=result.status,
  238. order_no=result.order_no,
  239. fund_order_id=result.fund_order_id,
  240. )
  241. # @classmethod
  242. # async def withdraw_service(
  243. # cls,
  244. # auth: AuthSchema,
  245. # data: AccountWithdrawSchema
  246. # ) -> AccountOperationOutSchema:
  247. # """
  248. # 资金专户提现
  249. # 调用: alipay.commerce.ec.trans.account.withdraw
  250. # """
  251. # from alipay.aop.api.request.AlipayCommerceEcTransAccountWithdrawRequest import (
  252. # AlipayCommerceEcTransAccountWithdrawRequest,
  253. # )
  254. # from alipay.aop.api.domain.AlipayCommerceEcTransAccountWithdrawModel import (
  255. # AlipayCommerceEcTransAccountWithdrawModel,
  256. # )
  257. # from alipay.aop.api.response.AlipayCommerceEcTransAccountWithdrawResponse import (
  258. # AlipayCommerceEcTransAccountWithdrawResponse,
  259. # )
  260. # crud = AccountCRUD(auth)
  261. # enterprise = await crud.get_by_enterprise_id(data.enterprise_id)
  262. # if not enterprise:
  263. # raise CustomException(msg="企业不存在")
  264. # model = AlipayCommerceEcTransAccountWithdrawModel()
  265. # model.enterprise_id = enterprise.alipay_enterprise_id
  266. # model.account_book_id = data.account_book_id
  267. # model.amount = str(data.amount)
  268. # model.out_biz_no = data.out_biz_no
  269. # request = AlipayCommerceEcTransAccountWithdrawRequest()
  270. # request.biz_model = model
  271. # client = AlipayClient.get_client()
  272. # response = client.execute(request)
  273. # if not response:
  274. # raise CustomException(msg="提现失败: 无响应")
  275. # result = AlipayCommerceEcTransAccountWithdrawResponse()
  276. # result.parse_response_content(response)
  277. # if not result.is_success():
  278. # log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  279. # raise CustomException(msg=f"提现失败: {result.msg}")
  280. # withdraw_crud = WithdrawCRUD(auth)
  281. # withdraw_data = {
  282. # "enterprise_id": data.enterprise_id,
  283. # "out_biz_no": data.out_biz_no,
  284. # "account_book_id": data.account_book_id,
  285. # "amount": data.amount,
  286. # "status": WithdrawStatusEnum.DEALING.value,
  287. # "order_no": result.order_no,
  288. # }
  289. # await withdraw_crud.create(withdraw_data)
  290. # return AccountOperationOutSchema(
  291. # enterprise_id=data.enterprise_id,
  292. # account_book_id=data.account_book_id,
  293. # out_biz_no=data.out_biz_no,
  294. # status=WithdrawStatusEnum.DEALING.value,
  295. # )
  296. @classmethod
  297. async def query_account_service(
  298. cls,
  299. auth: AuthSchema,
  300. data: AccountQuerySchema
  301. ) -> list[Any]:
  302. """
  303. 查询资金专户(调用支付宝接口)
  304. 调用: alipay.commerce.ec.trans.account.query
  305. """
  306. from alipay.aop.api.request.AlipayCommerceEcTransAccountQueryRequest import (
  307. AlipayCommerceEcTransAccountQueryRequest,
  308. )
  309. from alipay.aop.api.domain.AlipayCommerceEcTransAccountQueryModel import (
  310. AlipayCommerceEcTransAccountQueryModel,
  311. )
  312. from alipay.aop.api.response.AlipayCommerceEcTransAccountQueryResponse import (
  313. AlipayCommerceEcTransAccountQueryResponse,
  314. )
  315. from alipay.aop.api.domain.FundAccountApiDTO import (
  316. FundAccountApiDTO,
  317. )
  318. model = AlipayCommerceEcTransAccountQueryModel()
  319. model.enterprise_id = data.enterprise_id
  320. request = AlipayCommerceEcTransAccountQueryRequest()
  321. request.biz_model = model
  322. client = AlipayClient.get_client()
  323. response = client.execute(request)
  324. if not response:
  325. raise CustomException(msg="查询资金专户失败: 无响应")
  326. result = AlipayCommerceEcTransAccountQueryResponse()
  327. result.parse_response_content(response)
  328. if not result.is_success():
  329. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  330. raise CustomException(msg=f"查询资金专户失败: {result.msg}")
  331. collect = []
  332. for v in list(result.account_list or []):
  333. account = FundAccountApiDTO.to_alipay_dict(v)
  334. collect.append(account)
  335. return collect
  336. @classmethod
  337. async def transfer_detail_service(
  338. cls,
  339. auth: AuthSchema,
  340. out_biz_no: str
  341. ) -> TransferOutSchema:
  342. """
  343. 查询转账记录详情
  344. """
  345. crud = TransferCRUD(auth)
  346. transfer = await crud.get_by_out_biz_no(out_biz_no)
  347. if not transfer:
  348. raise CustomException(msg="转账记录不存在")
  349. return TransferOutSchema.model_validate(transfer)
  350. @classmethod
  351. async def transfer_list_service(
  352. cls,
  353. auth: AuthSchema,
  354. page_no: int = 1,
  355. page_size: int = 20,
  356. search: dict | None = None,
  357. ) -> dict:
  358. """
  359. 查询转账记录列表
  360. """
  361. log.info(f"查询转账记录列表: {page_no}, {page_size}, {search}")
  362. crud = TransferCRUD(auth)
  363. offset = (page_no - 1) * page_size
  364. return await crud.page(
  365. offset=offset,
  366. limit=page_size,
  367. order_by=[{"id": "desc"}],
  368. search=search or {},
  369. out_schema=TransferListOutSchema,
  370. )
  371. @classmethod
  372. async def update_transfer_status_service(
  373. cls,
  374. auth: AuthSchema,
  375. order_no: str,
  376. status: str,
  377. ext_info: dict = {}
  378. ) -> None:
  379. """
  380. 更新转账状态(由通知处理器调用)
  381. """
  382. crud = TransferCRUD(auth)
  383. transfer = await crud.get_by_order_no(order_no)
  384. if not transfer:
  385. log.warning(f"转账记录不存在: {order_no}")
  386. return
  387. update_data = {}
  388. update_data["status"] = status
  389. if ext_info:
  390. update_data["ext_info"] = ext_info
  391. await crud.update_by_order_no(order_no, update_data)
  392. @classmethod
  393. async def update_deposit_status_service(
  394. cls,
  395. auth: AuthSchema,
  396. out_biz_no: str,
  397. status: str,
  398. ) -> None:
  399. """
  400. 更新充值状态(由通知处理器调用)
  401. """
  402. crud = DepositCRUD(auth)
  403. deposit = await crud.get_by_out_biz_no(out_biz_no)
  404. if not deposit:
  405. log.warning(f"充值记录不存在: {out_biz_no}")
  406. return
  407. update_data = {"status": status}
  408. await crud.update_by_out_biz_no(out_biz_no, update_data)
  409. @classmethod
  410. async def update_withdraw_status_service(
  411. cls,
  412. auth: AuthSchema,
  413. out_biz_no: str,
  414. status: str,
  415. error_code: str | None = None,
  416. error_msg: str | None = None,
  417. ) -> None:
  418. """
  419. 更新提现状态(由通知处理器调用)
  420. """
  421. crud = WithdrawCRUD(auth)
  422. withdraw = await crud.get_by_out_biz_no(out_biz_no)
  423. if not withdraw:
  424. log.warning(f"提现记录不存在: {out_biz_no}")
  425. return
  426. update_data = {"status": status}
  427. if error_code:
  428. update_data["error_code"] = error_code
  429. if error_msg:
  430. update_data["error_msg"] = error_msg
  431. await crud.update_by_out_biz_no(out_biz_no, update_data)
  432. @classmethod
  433. async def consume_detail_query_service(
  434. cls,
  435. auth: AuthSchema,
  436. pay_no: str,
  437. enterprise_id: str | None = None,
  438. ant_shop_id: str | None = None,
  439. query_options: list[str] | None = None,
  440. ) -> dict:
  441. """
  442. 账单详情查询(✅)
  443. 调用: alipay.commerce.ec.consume.detail.query
  444. 用于查询企业码账单详情,支持查询关联退款、订单、票据等信息。
  445. """
  446. from alipay.aop.api.request.AlipayCommerceEcConsumeDetailQueryRequest import (
  447. AlipayCommerceEcConsumeDetailQueryRequest,
  448. )
  449. from alipay.aop.api.domain.AlipayCommerceEcConsumeDetailQueryModel import (
  450. AlipayCommerceEcConsumeDetailQueryModel,
  451. )
  452. from alipay.aop.api.response.AlipayCommerceEcConsumeDetailQueryResponse import (
  453. AlipayCommerceEcConsumeDetailQueryResponse,
  454. )
  455. model = AlipayCommerceEcConsumeDetailQueryModel()
  456. model.pay_no = pay_no
  457. if enterprise_id:
  458. model.enterprise_id = enterprise_id
  459. if ant_shop_id:
  460. model.ant_shop_id = ant_shop_id
  461. if query_options:
  462. model.query_options = query_options
  463. request = AlipayCommerceEcConsumeDetailQueryRequest()
  464. request.biz_model = model
  465. client = AlipayClient.get_client()
  466. response = client.execute(request)
  467. if not response:
  468. raise CustomException(msg="账单详情查询失败: 无响应")
  469. result = AlipayCommerceEcConsumeDetailQueryResponse()
  470. result.parse_response_content(response)
  471. if not result.is_success():
  472. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  473. raise CustomException(msg=f"账单详情查询失败: {result.msg}")
  474. consume_info = result.consume_info
  475. if not consume_info:
  476. raise CustomException(msg="账单详情查询失败: 无账单信息")
  477. return {
  478. "account_id": consume_info.account_id,
  479. "pay_no": consume_info.pay_no,
  480. "consume_type": consume_info.consume_type,
  481. "gmt_biz_create": consume_info.gmt_biz_create,
  482. "consume_biz_type": consume_info.consume_biz_type,
  483. "consume_amount": consume_info.consume_amount,
  484. "order_complete_label": consume_info.order_complete_label,
  485. "refund_status": consume_info.refund_status,
  486. "refund_amount": consume_info.refund_amount,
  487. "peer_payer_card_name": consume_info.peer_payer_card_name,
  488. "user_id": getattr(consume_info, 'user_id', None),
  489. "open_id": getattr(consume_info, 'open_id', None),
  490. "enterprise_id": consume_info.enterprise_id,
  491. "employee_id": consume_info.employee_id,
  492. "enterprise_name": getattr(consume_info, 'enterprise_name', None),
  493. "employee_name": getattr(consume_info, 'employee_name', None),
  494. "consume_scene_code": getattr(consume_info, 'consume_scene_code', None),
  495. "consume_type_sub_category": getattr(consume_info, 'consume_type_sub_category', None),
  496. "consume_title": getattr(consume_info, 'consume_title', None),
  497. "gmt_pay": getattr(consume_info, 'gmt_pay', None),
  498. "gmt_refund": getattr(consume_info, 'gmt_refund', None),
  499. "pay_amount": getattr(consume_info, 'pay_amount', None),
  500. "invoice_amount": getattr(consume_info, 'invoice_amount', None),
  501. "peer_pay_amount": getattr(consume_info, 'peer_pay_amount', None),
  502. "subsidy_amount": getattr(consume_info, 'subsidy_amount', None),
  503. "ext_infos": getattr(consume_info, 'ext_infos', None),
  504. }