service.py 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120
  1. from datetime import datetime
  2. from decimal import Decimal
  3. from typing import Any, Optional
  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. WithdrawStatusEnum,
  15. )
  16. from .schema import (
  17. AccountAuthorizeApplySchema,
  18. AccountAuthorizeApplyOutSchema,
  19. AccountCreateSchema,
  20. AccountDepositSchema,
  21. AccountDepositOutSchema,
  22. AccountOperationOutSchema,
  23. AccountQuerySchema,
  24. AccountTransferSchema,
  25. AccountTransferOutSchema,
  26. AccountWithdrawSchema,
  27. ReceiptApplySchema,
  28. TransferListOutSchema,
  29. TransferOutSchema,
  30. TenantTransferCreate,
  31. TenantTransferResponse,
  32. )
  33. from ..openapi.crud import OpenTransferCRUD
  34. class AccountService:
  35. """资金专户服务层"""
  36. @classmethod
  37. async def stat_transfer_amount_service(
  38. cls,
  39. auth: AuthSchema,
  40. tenant_id: Optional[int] = None,
  41. enterprise_id: Optional[str] = None,
  42. start_date: Optional[datetime] = None,
  43. end_date: Optional[datetime] = None,
  44. ) -> Decimal:
  45. """
  46. 统计转账金额(✅)
  47. 统计企业在指定时间范围内的转账总金额以及每天的转账金额。
  48. """
  49. crud = TransferCRUD(auth)
  50. return await crud.get_transfer_amount(
  51. tenant_id=tenant_id,
  52. enterprise_id=enterprise_id,
  53. start_date=start_date,
  54. end_date=end_date,
  55. )
  56. @classmethod
  57. async def authorize_apply_service(
  58. cls,
  59. auth: AuthSchema,
  60. data: AccountAuthorizeApplySchema
  61. ) -> AccountAuthorizeApplyOutSchema:
  62. """
  63. 申请转账授权签约(✅)
  64. 调用: alipay.commerce.ec.trans.authorize.apply
  65. """
  66. from alipay.aop.api.request.AlipayCommerceEcTransAuthorizeApplyRequest import (
  67. AlipayCommerceEcTransAuthorizeApplyRequest,
  68. )
  69. from alipay.aop.api.domain.AlipayCommerceEcTransAuthorizeApplyModel import (
  70. AlipayCommerceEcTransAuthorizeApplyModel,
  71. )
  72. from alipay.aop.api.response.AlipayCommerceEcTransAuthorizeApplyResponse import (
  73. AlipayCommerceEcTransAuthorizeApplyResponse,
  74. )
  75. model = AlipayCommerceEcTransAuthorizeApplyModel()
  76. model.enterprise_id = data.enterprise_id
  77. request = AlipayCommerceEcTransAuthorizeApplyRequest()
  78. request.biz_model = model
  79. client = AlipayClient.get_client()
  80. response = client.execute(request)
  81. if not response:
  82. raise CustomException(msg="申请转账授权失败: 无响应")
  83. result = AlipayCommerceEcTransAuthorizeApplyResponse()
  84. result.parse_response_content(response)
  85. if not result.is_success():
  86. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  87. raise CustomException(msg=f"申请转账授权失败: {result.msg}")
  88. return AccountAuthorizeApplyOutSchema(
  89. sign_url=result.sign_url,
  90. )
  91. @classmethod
  92. async def create_account_service(
  93. cls,
  94. auth: AuthSchema,
  95. data: AccountCreateSchema
  96. ) -> AccountOperationOutSchema:
  97. """
  98. 开通资金专户(✅)
  99. 调用: alipay.commerce.ec.trans.account.create
  100. """
  101. from alipay.aop.api.request.AlipayCommerceEcTransAccountCreateRequest import (
  102. AlipayCommerceEcTransAccountCreateRequest,
  103. )
  104. from alipay.aop.api.domain.AlipayCommerceEcTransAccountCreateModel import (
  105. AlipayCommerceEcTransAccountCreateModel,
  106. )
  107. from alipay.aop.api.response.AlipayCommerceEcTransAccountCreateResponse import (
  108. AlipayCommerceEcTransAccountCreateResponse,
  109. )
  110. model = AlipayCommerceEcTransAccountCreateModel()
  111. model.enterprise_id = data.enterprise_id
  112. # model.account_type = data.account_type or "ALL" # 收支全能户
  113. # model.scene = data.scene or "B2B_TRANS" # ToB转账场景
  114. model.account_type = "ALL"
  115. model.scene = "B2B_TRANS"
  116. request = AlipayCommerceEcTransAccountCreateRequest()
  117. request.biz_model = model
  118. client = AlipayClient.get_client()
  119. response = client.execute(request)
  120. if not response:
  121. raise CustomException(msg="开通资金专户失败: 无响应")
  122. result = AlipayCommerceEcTransAccountCreateResponse()
  123. result.parse_response_content(response)
  124. if not result.is_success():
  125. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  126. raise CustomException(msg=f"开通资金专户失败: {result.msg}")
  127. account_data = AccountCreateSchema(
  128. enterprise_id=model.enterprise_id,
  129. account_book_id=result.account_book_id,
  130. account_type=model.account_type,
  131. scene=model.scene,
  132. )
  133. if result.account_book_id:
  134. account_data.account_book_id = result.account_book_id
  135. await AccountCRUD(auth).create(account_data)
  136. return AccountOperationOutSchema(
  137. enterprise_id=account_data.enterprise_id,
  138. account_book_id=account_data.account_book_id,
  139. )
  140. @classmethod
  141. async def deposit_service(
  142. cls,
  143. auth: AuthSchema,
  144. data: AccountDepositSchema
  145. ) -> AccountDepositOutSchema:
  146. """
  147. 资金专户充值(✅)
  148. 调用: alipay.commerce.ec.trans.account.deposit
  149. """
  150. from alipay.aop.api.request.AlipayCommerceEcTransAccountDepositRequest import (
  151. AlipayCommerceEcTransAccountDepositRequest,
  152. )
  153. from alipay.aop.api.domain.AlipayCommerceEcTransAccountDepositModel import (
  154. AlipayCommerceEcTransAccountDepositModel,
  155. )
  156. from alipay.aop.api.response.AlipayCommerceEcTransAccountDepositResponse import (
  157. AlipayCommerceEcTransAccountDepositResponse,
  158. )
  159. model = AlipayCommerceEcTransAccountDepositModel()
  160. model.enterprise_id = data.enterprise_id
  161. model.account_book_id = data.account_book_id
  162. model.amount = str(data.amount)
  163. model.out_biz_no = get_snowflake_id_str(auth.tenant_id)
  164. request = AlipayCommerceEcTransAccountDepositRequest()
  165. request.biz_model = model
  166. client = AlipayClient.get_client()
  167. response = client.execute(request)
  168. if not response:
  169. raise CustomException(msg="充值失败: 无响应")
  170. result = AlipayCommerceEcTransAccountDepositResponse()
  171. result.parse_response_content(response)
  172. if not result.is_success():
  173. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  174. raise CustomException(msg=f"充值失败: {result.msg}")
  175. deposit_crud = DepositCRUD(auth)
  176. deposit_data = {
  177. "enterprise_id": data.enterprise_id,
  178. "out_biz_no": model.out_biz_no,
  179. "account_book_id": data.account_book_id,
  180. "amount": data.amount,
  181. "url": result.url,
  182. "status": DepositStatusEnum.DEALING.value,
  183. "remark": data.remark,
  184. }
  185. await deposit_crud.create(deposit_data)
  186. return AccountDepositOutSchema(
  187. url=result.url,
  188. )
  189. @classmethod
  190. async def transfer_service(
  191. cls,
  192. auth: AuthSchema,
  193. data: AccountTransferSchema
  194. ) -> AccountTransferOutSchema:
  195. """
  196. 资金专户转账(✅)
  197. 调用: alipay.commerce.ec.trans.account.transfer
  198. """
  199. from alipay.aop.api.request.AlipayCommerceEcTransAccountTransferRequest import (
  200. AlipayCommerceEcTransAccountTransferRequest,
  201. )
  202. from alipay.aop.api.domain.AlipayCommerceEcTransAccountTransferModel import (
  203. AlipayCommerceEcTransAccountTransferModel,
  204. )
  205. from alipay.aop.api.response.AlipayCommerceEcTransAccountTransferResponse import (
  206. AlipayCommerceEcTransAccountTransferResponse,
  207. )
  208. from alipay.aop.api.domain.TransParticipant import (
  209. TransParticipant,
  210. )
  211. from alipay.aop.api.domain.BankCardExtInfoDTO import (
  212. BankCardExtInfoDTO,
  213. )
  214. # 检查资金专户是否存在
  215. account = await AccountCRUD(auth).get_by_account_book_id(data.account_book_id)
  216. if not account:
  217. raise CustomException(msg="资金账户不存在")
  218. if account.tenant_id != auth.tenant_id:
  219. raise CustomException(msg="无权限操作")
  220. if data.enterprise_id and account.enterprise_id != data.enterprise_id:
  221. raise CustomException(msg="参数错误")
  222. if not data.order_title and account.enterprise_id:
  223. enterprise = await EnterpriseCRUD(auth).get_by_enterprise_id(account.enterprise_id)
  224. if not enterprise:
  225. raise CustomException(msg="资金账户所属企业不存在")
  226. data.order_title = f"来自{enterprise.name}转账"
  227. model = AlipayCommerceEcTransAccountTransferModel()
  228. model.enterprise_id = account.enterprise_id
  229. model.account_book_id = account.account_book_id
  230. model.out_biz_no = get_snowflake_id_str(auth.tenant_id)
  231. # 转账总金额,单位为元,精确到小数点后两位
  232. model.amount = str(data.amount)
  233. model.order_title = data.order_title
  234. payee_info = TransParticipant()
  235. payee_info.identity_type = data.payee_info.identity_type
  236. payee_info.name = data.payee_info.name
  237. payee_info.identity = data.payee_info.identity
  238. if data.payee_info.bankcard_ext_info:
  239. payee_info.bankcard_ext_info = BankCardExtInfoDTO.from_alipay_dict(
  240. data.payee_info.bankcard_ext_info.model_dump(exclude_none=True)
  241. )
  242. model.payee_info = payee_info
  243. request = AlipayCommerceEcTransAccountTransferRequest()
  244. request.biz_model = model
  245. client = AlipayClient.get_client()
  246. response = client.execute(request)
  247. if not response:
  248. raise CustomException(msg="转账失败: 无响应")
  249. result = AlipayCommerceEcTransAccountTransferResponse()
  250. result.parse_response_content(response)
  251. if not result.is_success():
  252. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  253. raise CustomException(msg=f"转账失败: {result.sub_msg or result.msg or result.code}")
  254. transfer_crud = TransferCRUD(auth)
  255. transfer_data = {
  256. "enterprise_id": model.enterprise_id,
  257. "out_biz_no": model.out_biz_no,
  258. "account_book_id": model.account_book_id,
  259. "amount": model.amount,
  260. "order_title": model.order_title,
  261. "payee_info": data.payee_info.model_dump() if data.payee_info else None,
  262. "status": result.status,
  263. "order_no": result.order_no,
  264. "fund_order_id": result.fund_order_id,
  265. }
  266. await transfer_crud.create(transfer_data)
  267. return AccountTransferOutSchema(
  268. status=result.status,
  269. order_no=result.order_no,
  270. fund_order_id=result.fund_order_id,
  271. out_biz_no=model.out_biz_no,
  272. )
  273. @classmethod
  274. async def tenant_transfer_service(
  275. cls,
  276. auth: AuthSchema,
  277. tenant_id: int,
  278. data: TenantTransferCreate,
  279. request_ip: str,
  280. api_key_id: int | None = None,
  281. ) -> TenantTransferResponse:
  282. """
  283. 租户API转账(通过API Key认证)
  284. 调用: alipay.commerce.ec.trans.account.transfer
  285. """
  286. from alipay.aop.api.request.AlipayCommerceEcTransAccountTransferRequest import (
  287. AlipayCommerceEcTransAccountTransferRequest,
  288. )
  289. from alipay.aop.api.domain.AlipayCommerceEcTransAccountTransferModel import (
  290. AlipayCommerceEcTransAccountTransferModel,
  291. )
  292. from alipay.aop.api.response.AlipayCommerceEcTransAccountTransferResponse import (
  293. AlipayCommerceEcTransAccountTransferResponse,
  294. )
  295. from alipay.aop.api.domain.TransParticipant import (
  296. TransParticipant,
  297. )
  298. from alipay.aop.api.domain.BankCardExtInfoDTO import (
  299. BankCardExtInfoDTO,
  300. )
  301. # 检查资金专户是否存在
  302. account = await AccountCRUD(auth).get_by_account_book_id(data.account_book_id)
  303. if not account:
  304. raise CustomException(msg="资金账户不存在")
  305. if account.tenant_id != tenant_id:
  306. raise CustomException(msg="无权限操作")
  307. if data.enterprise_id and account.enterprise_id != data.enterprise_id:
  308. raise CustomException(msg="参数错误")
  309. if not data.order_title and account.enterprise_id:
  310. enterprise = await EnterpriseCRUD(auth).get_by_enterprise_id(account.enterprise_id)
  311. if not enterprise:
  312. raise CustomException(msg="资金账户所属企业不存在")
  313. data.order_title = f"来自{enterprise.name}转账"
  314. model = AlipayCommerceEcTransAccountTransferModel()
  315. model.enterprise_id = account.enterprise_id
  316. model.account_book_id = account.account_book_id
  317. model.out_biz_no = get_snowflake_id_str(tenant_id)
  318. # 转账总金额,单位为元,精确到小数点后两位
  319. model.amount = str(data.amount)
  320. model.order_title = data.order_title
  321. payee_info = TransParticipant()
  322. payee_info.identity_type = data.payee_info.identity_type
  323. payee_info.name = data.payee_info.name
  324. payee_info.identity = data.payee_info.identity
  325. if data.payee_info.bankcard_ext_info:
  326. payee_info.bankcard_ext_info = BankCardExtInfoDTO.from_alipay_dict(
  327. data.payee_info.bankcard_ext_info.model_dump(exclude_none=True)
  328. )
  329. model.payee_info = payee_info
  330. request = AlipayCommerceEcTransAccountTransferRequest()
  331. request.biz_model = model
  332. client = AlipayClient.get_client()
  333. response = client.execute(request)
  334. if not response:
  335. raise CustomException(msg="转账失败: 无响应")
  336. result = AlipayCommerceEcTransAccountTransferResponse()
  337. result.parse_response_content(response)
  338. if not result.is_success():
  339. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  340. raise CustomException(msg=f"转账失败: {result.sub_msg or result.msg or result.code}")
  341. transfer_crud = TransferCRUD(auth)
  342. transfer_data = {
  343. "enterprise_id": model.enterprise_id,
  344. "out_biz_no": model.out_biz_no,
  345. "account_book_id": model.account_book_id,
  346. "amount": model.amount,
  347. "order_title": model.order_title,
  348. "payee_info": data.payee_info.model_dump() if data.payee_info else None,
  349. "status": result.status,
  350. "order_no": result.order_no,
  351. "fund_order_id": result.fund_order_id,
  352. }
  353. await transfer_crud.create(transfer_data)
  354. return TenantTransferResponse(
  355. status=result.status,
  356. order_no=result.order_no,
  357. fund_order_id=result.fund_order_id,
  358. )
  359. @classmethod
  360. async def withdraw_service(
  361. cls,
  362. auth: AuthSchema,
  363. data: AccountWithdrawSchema
  364. ) -> AccountOperationOutSchema:
  365. """
  366. 资金专户提现
  367. 调用: alipay.commerce.ec.trans.account.withdraw
  368. 接口文档: https://opendocs.alipay.com/pre-open/d651859b_alipay.commerce.ec.trans.account.withdraw
  369. 参数说明:
  370. - enterprise_id: 企业ID
  371. - account_book_id: 资金专户号
  372. - amount: 提现金额
  373. - out_biz_no: 商家侧订单号(唯一)
  374. """
  375. from alipay.aop.api.request.AlipayCommerceEcTransAccountWithdrawRequest import (
  376. AlipayCommerceEcTransAccountWithdrawRequest,
  377. )
  378. from alipay.aop.api.domain.AlipayCommerceEcTransAccountWithdrawModel import (
  379. AlipayCommerceEcTransAccountWithdrawModel,
  380. )
  381. from alipay.aop.api.response.AlipayCommerceEcTransAccountWithdrawResponse import (
  382. AlipayCommerceEcTransAccountWithdrawResponse,
  383. )
  384. crud = AccountCRUD(auth)
  385. enterprise = await crud.get_by_enterprise_id(data.enterprise_id)
  386. if not enterprise:
  387. raise CustomException(msg="企业不存在")
  388. model = AlipayCommerceEcTransAccountWithdrawModel()
  389. model.enterprise_id = enterprise.enterprise_id
  390. model.account_book_id = data.account_book_id
  391. model.amount = str(data.amount)
  392. model.out_biz_no = get_snowflake_id_str(auth.tenant_id)
  393. request = AlipayCommerceEcTransAccountWithdrawRequest()
  394. request.biz_model = model
  395. client = AlipayClient.get_client()
  396. response = client.execute(request)
  397. if not response:
  398. raise CustomException(msg="提现失败: 无响应")
  399. result = AlipayCommerceEcTransAccountWithdrawResponse()
  400. result.parse_response_content(response)
  401. if not result.is_success():
  402. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  403. raise CustomException(msg=f"提现失败: {result.msg}")
  404. withdraw_crud = WithdrawCRUD(auth)
  405. withdraw_data = {
  406. "enterprise_id": data.enterprise_id,
  407. "out_biz_no": model.out_biz_no,
  408. "account_book_id": data.account_book_id,
  409. "amount": data.amount,
  410. # 专户提现到余额户是同步操作,要么执行成功,要么执行异常,
  411. # 出参status设计多余,遵循规范使用业务码区分成功与失败
  412. "status": WithdrawStatusEnum.SUCCESS.value,
  413. "order_no": result.order_no,
  414. }
  415. await withdraw_crud.create(withdraw_data)
  416. log.info(f"资金专户提现发起成功: 企业: {data.enterprise_id}, 金额: {data.amount}")
  417. return AccountOperationOutSchema(
  418. enterprise_id=data.enterprise_id,
  419. account_book_id=data.account_book_id,
  420. )
  421. @classmethod
  422. async def query_account_service(
  423. cls,
  424. auth: AuthSchema,
  425. data: AccountQuerySchema
  426. ) -> list[Any]:
  427. """
  428. 查询资金专户(调用支付宝接口)
  429. 调用: alipay.commerce.ec.trans.account.query
  430. """
  431. from alipay.aop.api.request.AlipayCommerceEcTransAccountQueryRequest import (
  432. AlipayCommerceEcTransAccountQueryRequest,
  433. )
  434. from alipay.aop.api.domain.AlipayCommerceEcTransAccountQueryModel import (
  435. AlipayCommerceEcTransAccountQueryModel,
  436. )
  437. from alipay.aop.api.response.AlipayCommerceEcTransAccountQueryResponse import (
  438. AlipayCommerceEcTransAccountQueryResponse,
  439. )
  440. from alipay.aop.api.domain.FundAccountApiDTO import (
  441. FundAccountApiDTO,
  442. )
  443. model = AlipayCommerceEcTransAccountQueryModel()
  444. model.enterprise_id = data.enterprise_id
  445. request = AlipayCommerceEcTransAccountQueryRequest()
  446. request.biz_model = model
  447. client = AlipayClient.get_client()
  448. response = client.execute(request)
  449. if not response:
  450. raise CustomException(msg="查询资金专户失败: 无响应")
  451. result = AlipayCommerceEcTransAccountQueryResponse()
  452. result.parse_response_content(response)
  453. if not result.is_success():
  454. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  455. raise CustomException(msg=f"查询资金专户失败: {result.msg}")
  456. collect = []
  457. for v in list(result.account_list or []):
  458. if not hasattr(v, "account_book_id"):
  459. continue
  460. if not hasattr(v, "scene") or v.scene != "B2B_TRANS":
  461. continue
  462. account = FundAccountApiDTO.to_alipay_dict(v)
  463. collect.append(account)
  464. return collect
  465. @classmethod
  466. async def transfer_detail_service(
  467. cls,
  468. auth: AuthSchema,
  469. out_biz_no: str
  470. ) -> TransferOutSchema:
  471. """
  472. 查询转账记录详情
  473. """
  474. crud = TransferCRUD(auth)
  475. transfer = await crud.get_by_out_biz_no(out_biz_no)
  476. if not transfer:
  477. raise CustomException(msg="转账记录不存在")
  478. transfer_result = TransferOutSchema.model_validate(transfer)
  479. # 查询三方订单号
  480. open_transfer_crud = OpenTransferCRUD(auth)
  481. open_transfer_data = await open_transfer_crud.get(out_biz_no=transfer.out_biz_no)
  482. if open_transfer_data:
  483. transfer_result.third_biz_no = open_transfer_data.third_biz_no
  484. return transfer_result
  485. @classmethod
  486. async def transfer_list_service(
  487. cls,
  488. auth: AuthSchema,
  489. page_no: int = 1,
  490. page_size: int = 20,
  491. search: dict | None = None,
  492. ) -> dict:
  493. """
  494. 查询转账记录列表
  495. """
  496. log.info(f"查询转账记录列表: {page_no}, {page_size}, {search}")
  497. crud = TransferCRUD(auth)
  498. offset = (page_no - 1) * page_size
  499. return await crud.page(
  500. offset=offset,
  501. limit=page_size,
  502. order_by=[{"id": "desc"}],
  503. search=search or {},
  504. out_schema=TransferListOutSchema,
  505. )
  506. @classmethod
  507. async def transfer_export_service(
  508. cls,
  509. auth: AuthSchema,
  510. start_time: str,
  511. end_time: str,
  512. enterprise_id: Optional[str] = None,
  513. ) -> bytes:
  514. """
  515. 导出转账记录报表为Excel文件
  516. """
  517. log.info(f"导出转账记录报表: {start_time} -> {end_time}")
  518. crud = TransferCRUD(auth)
  519. search = {
  520. "created_time__gte": start_time,
  521. "created_time__lte": end_time,
  522. }
  523. if enterprise_id:
  524. search["enterprise_id"] = enterprise_id
  525. records = await crud.list(
  526. search=search,
  527. order_by=[{"id": "desc"}],
  528. )
  529. from app.utils.excel_util import ExcelUtil
  530. status_map = {
  531. "DEALING": "处理中",
  532. "SUCCESS": "成功",
  533. "FAIL": "失败",
  534. "REFUND": "退票",
  535. }
  536. payee_type_map = {
  537. "ALIPAY_ACCOUNT": "支付宝账户",
  538. "BANK_CARD": "银行卡",
  539. }
  540. list_data = []
  541. for i, record in enumerate(records, start=1):
  542. payee_info = record.payee_info or {}
  543. list_data.append({
  544. "序号": i,
  545. "订单号": record.out_biz_no or "",
  546. "商户订单号": record.order_no or "",
  547. "金额(元)": str(record.amount or 0),
  548. "收款方姓名": payee_info.get("name", ""),
  549. "收款方类型": payee_type_map.get(payee_info.get("identity_type", ""), ""),
  550. "状态": status_map.get(record.status, record.status),
  551. "转账标题": record.order_title or "",
  552. "创建时间": record.created_time.strftime("%Y-%m-%d %H:%M:%S") if record.created_time else "",
  553. })
  554. mapping_dict = {
  555. "序号": "序号",
  556. "订单号": "订单号",
  557. "商户订单号": "商户订单号",
  558. "金额(元)": "金额(元)",
  559. "收款方姓名": "收款方姓名",
  560. "收款方类型": "收款方类型",
  561. "状态": "状态",
  562. "转账标题": "转账标题",
  563. "创建时间": "创建时间",
  564. }
  565. return ExcelUtil.export_list2excel(list_data, mapping_dict)
  566. @classmethod
  567. async def apply_receipt_service(
  568. cls,
  569. auth: AuthSchema,
  570. redis: Redis,
  571. data: ReceiptApplySchema,
  572. ) -> str:
  573. """
  574. 申请转账业务回单
  575. 调用: alipay.commerce.ec.trans.receipt.apply
  576. 参数:
  577. - enterprise_id: 企业ID
  578. - order_no: 支付宝转账单号
  579. 返回: file_id
  580. """
  581. from app.core.redis_crud import RedisCURD
  582. redis_crud = RedisCURD(redis)
  583. cache_key = f"receipt:{data.enterprise_id}:{data.order_no}"
  584. cached_file_id = await redis_crud.get(cache_key)
  585. if cached_file_id:
  586. log.info(f"使用缓存的 file_id: {cached_file_id}")
  587. return cached_file_id
  588. crud = EnterpriseCRUD(auth)
  589. enterprise = await crud.get_by_enterprise_id(data.enterprise_id)
  590. if not enterprise:
  591. raise CustomException(msg="企业不存在")
  592. from alipay.aop.api.request.AlipayCommerceEcTransReceiptApplyRequest import (
  593. AlipayCommerceEcTransReceiptApplyRequest,
  594. )
  595. from alipay.aop.api.domain.AlipayCommerceEcTransReceiptApplyModel import (
  596. AlipayCommerceEcTransReceiptApplyModel,
  597. )
  598. from alipay.aop.api.response.AlipayCommerceEcTransReceiptApplyResponse import (
  599. AlipayCommerceEcTransReceiptApplyResponse,
  600. )
  601. model = AlipayCommerceEcTransReceiptApplyModel()
  602. model.enterprise_id = data.enterprise_id
  603. model.order_no = data.order_no
  604. request = AlipayCommerceEcTransReceiptApplyRequest()
  605. request.biz_model = model
  606. client = AlipayClient.get_client()
  607. response = client.execute(request)
  608. if not response:
  609. raise CustomException(msg="申请回单失败: 无响应")
  610. result = AlipayCommerceEcTransReceiptApplyResponse()
  611. result.parse_response_content(response)
  612. if not result.is_success():
  613. # 清除缓存
  614. await redis_crud.delete(cache_key)
  615. raise CustomException(msg=f"申请回单失败: {result.msg}")
  616. file_id = str(result.file_id)
  617. await redis_crud.set(cache_key, file_id, expire=172800)
  618. log.info(f"申请回单成功: order_no={data.order_no}, file_id={file_id}")
  619. return file_id
  620. @classmethod
  621. async def query_receipt_service(cls, enterprise_id: str, file_id: str) -> dict:
  622. """
  623. 查询回单状态
  624. 调用: alipay.commerce.ec.trans.receipt.query
  625. 参数:
  626. - file_id: 文件申请号
  627. 返回: {file_id, status, download_url, error_message}
  628. """
  629. from alipay.aop.api.request.AlipayCommerceEcTransReceiptQueryRequest import (
  630. AlipayCommerceEcTransReceiptQueryRequest,
  631. )
  632. from alipay.aop.api.response.AlipayCommerceEcTransReceiptQueryResponse import (
  633. AlipayCommerceEcTransReceiptQueryResponse,
  634. )
  635. from alipay.aop.api.domain.AlipayCommerceEcTransReceiptQueryModel import (
  636. AlipayCommerceEcTransReceiptQueryModel,
  637. )
  638. model = AlipayCommerceEcTransReceiptQueryModel()
  639. model.enterprise_id = enterprise_id
  640. model.file_id = file_id
  641. request = AlipayCommerceEcTransReceiptQueryRequest()
  642. request.biz_model = model
  643. client = AlipayClient.get_client()
  644. response = client.execute(request)
  645. if not response:
  646. raise CustomException(msg="查询回单失败: 无响应")
  647. result = AlipayCommerceEcTransReceiptQueryResponse()
  648. result.parse_response_content(response)
  649. if not result.is_success():
  650. raise CustomException(msg=f"查询回单失败: {result.msg}")
  651. data = {
  652. "file_id": file_id,
  653. "status": result.status,
  654. "download_url": result.download_url,
  655. "error_message": result.error_message,
  656. }
  657. return data
  658. @classmethod
  659. async def transfer_sync_status_service(
  660. cls,
  661. auth: AuthSchema,
  662. data: "TransferSyncStatusSchema",
  663. ) -> dict:
  664. """
  665. 手动同步转账状态(管理员补录)
  666. 用于修复因通知丢失而卡在 DEALING 的转账记录
  667. """
  668. from app.plugin.module_payment.account.crud import TransferCRUD
  669. from app.plugin.module_payment.account.schema import TransferSyncStatusSchema
  670. crud = TransferCRUD(auth)
  671. transfer = await crud.get_by_out_biz_no(data.out_biz_no)
  672. if not transfer:
  673. raise CustomException(msg=f"转账记录不存在: {data.out_biz_no}")
  674. if transfer.status != "DEALING" and data.status == "SUCCESS":
  675. raise CustomException(msg=f"转账记录当前状态为 {transfer.status},无需同步")
  676. update_data = {"status": data.status}
  677. if data.error_code:
  678. update_data["error_code"] = data.error_code
  679. if data.error_msg:
  680. update_data["error_msg"] = data.error_msg
  681. for key, value in update_data.items():
  682. if hasattr(transfer, key):
  683. setattr(transfer, key, value)
  684. await auth.db.flush()
  685. await auth.db.refresh(transfer)
  686. log.info(f"手动同步转账状态成功: out_biz_no={data.out_biz_no}, {transfer.status}")
  687. return {
  688. "out_biz_no": transfer.out_biz_no,
  689. "status": transfer.status,
  690. "error_code": transfer.error_code,
  691. "error_msg": transfer.error_msg,
  692. }
  693. @classmethod
  694. async def transfer_sync_all_service(
  695. cls,
  696. auth: AuthSchema,
  697. ) -> dict:
  698. """
  699. 全量同步转账状态
  700. 自动查询所有卡在 DEALING 的转账,调用支付宝核实状态
  701. """
  702. from app.plugin.module_payment.account.crud import TransferCRUD
  703. from app.plugin.module_payment.enterprise.service import EnterpriseService
  704. from sqlalchemy import update as sa_update
  705. from app.plugin.module_payment.account.model import TransferModel
  706. from app.plugin.module_payment.account.enums import TransferStatusEnum
  707. # 获取所有企业
  708. enterprises = await EnterpriseService.all_service(auth=auth)
  709. if not enterprises:
  710. return {"total": 0, "synced": 0, "failed": 0, "details": []}
  711. try:
  712. from alipay.aop.api.request.AlipayCommerceEcTransOrderQueryRequest import (
  713. AlipayCommerceEcTransOrderQueryRequest,
  714. )
  715. from alipay.aop.api.domain.AlipayCommerceEcTransOrderQueryModel import (
  716. AlipayCommerceEcTransOrderQueryModel,
  717. )
  718. from alipay.aop.api.response.AlipayCommerceEcTransOrderQueryResponse import (
  719. AlipayCommerceEcTransOrderQueryResponse,
  720. )
  721. from app.core.alipay_client import AlipayClient
  722. except ImportError:
  723. raise CustomException(msg="支付宝SDK未正确安装")
  724. crud = TransferCRUD(auth)
  725. client = AlipayClient.get_client()
  726. synced = 0
  727. failed = 0
  728. total = 0
  729. details = []
  730. for enterprise in enterprises:
  731. eid = enterprise.enterprise_id
  732. dealing_transfers = await crud.list(search={
  733. "status": TransferStatusEnum.DEALING.value,
  734. "enterprise_id": eid,
  735. })
  736. if not dealing_transfers:
  737. continue
  738. for transfer in dealing_transfers:
  739. total += 1
  740. out_biz_no = transfer.out_biz_no
  741. if not out_biz_no:
  742. continue
  743. try:
  744. query_model = AlipayCommerceEcTransOrderQueryModel()
  745. query_model.out_biz_no = out_biz_no
  746. query_model.enterprise_id = eid
  747. request = AlipayCommerceEcTransOrderQueryRequest()
  748. request.biz_model = query_model
  749. response = client.execute(request)
  750. if not response:
  751. failed += 1
  752. details.append({"out_biz_no": out_biz_no, "status": "DEALING", "error": "无响应"})
  753. continue
  754. query_result = AlipayCommerceEcTransOrderQueryResponse()
  755. query_result.parse_response_content(response)
  756. if not query_result.is_success():
  757. failed += 1
  758. err_msg = query_result.sub_msg or query_result.msg
  759. details.append({"out_biz_no": out_biz_no, "status": "DEALING", "error": err_msg})
  760. log.warning(f"全量同步 - 查询失败: out_biz_no={out_biz_no}, error={err_msg}")
  761. continue
  762. alipay_status = getattr(query_result, 'status', None)
  763. if alipay_status and alipay_status != TransferStatusEnum.DEALING.value:
  764. update_data = {"status": alipay_status}
  765. order_no = getattr(query_result, 'order_no', None)
  766. fund_order_id = getattr(query_result, 'pay_fund_order_id', None)
  767. if order_no:
  768. update_data["order_no"] = order_no
  769. if fund_order_id:
  770. update_data["fund_order_id"] = fund_order_id
  771. upd = sa_update(TransferModel).where(
  772. TransferModel.out_biz_no == out_biz_no
  773. ).values(**update_data)
  774. await auth.db.execute(upd)
  775. synced += 1
  776. details.append({"out_biz_no": out_biz_no, "status": alipay_status, "synced": True})
  777. log.info(f"全量同步 - 已更新: out_biz_no={out_biz_no}, old=DEALING, new={alipay_status}")
  778. else:
  779. details.append({"out_biz_no": out_biz_no, "status": "DEALING", "error": "支付宝侧仍为处理中"})
  780. except Exception as e:
  781. failed += 1
  782. details.append({"out_biz_no": out_biz_no, "status": "DEALING", "error": str(e)})
  783. log.warning(f"全量同步 - 异常: out_biz_no={out_biz_no}, error={e}")
  784. if synced > 0 or failed > 0:
  785. await auth.db.flush()
  786. return {
  787. "total": total,
  788. "synced": synced,
  789. "failed": failed,
  790. "details": details,
  791. }
  792. @classmethod
  793. async def update_transfer_status_service(
  794. cls,
  795. auth: AuthSchema,
  796. order_no: str,
  797. status: str,
  798. ext_info: dict = {}
  799. ) -> None:
  800. """
  801. 更新转账状态(由通知处理器调用)
  802. """
  803. crud = TransferCRUD(auth)
  804. transfer = await crud.get_by_order_no(order_no)
  805. if not transfer:
  806. log.warning(f"转账记录不存在: {order_no}")
  807. return
  808. update_data = {}
  809. update_data["status"] = status
  810. if ext_info:
  811. update_data["ext_info"] = ext_info
  812. await crud.update_by_order_no(order_no, update_data)
  813. @classmethod
  814. async def update_deposit_status_service(
  815. cls,
  816. auth: AuthSchema,
  817. out_biz_no: str,
  818. status: str,
  819. ) -> None:
  820. """
  821. 更新充值状态(由通知处理器调用)
  822. """
  823. crud = DepositCRUD(auth)
  824. deposit = await crud.get_by_out_biz_no(out_biz_no)
  825. if not deposit:
  826. log.warning(f"充值记录不存在: {out_biz_no}")
  827. return
  828. update_data = {"status": status}
  829. await crud.update_by_out_biz_no(out_biz_no, update_data)
  830. @classmethod
  831. async def update_withdraw_status_service(
  832. cls,
  833. auth: AuthSchema,
  834. out_biz_no: str,
  835. status: str,
  836. error_code: str | None = None,
  837. error_msg: str | None = None,
  838. ) -> None:
  839. """
  840. 更新提现状态(由通知处理器调用)
  841. """
  842. crud = WithdrawCRUD(auth)
  843. withdraw = await crud.get_by_out_biz_no(out_biz_no)
  844. if not withdraw:
  845. log.warning(f"提现记录不存在: {out_biz_no}")
  846. return
  847. update_data = {"status": status}
  848. if error_code:
  849. update_data["error_code"] = error_code
  850. if error_msg:
  851. update_data["error_msg"] = error_msg
  852. await crud.update_by_out_biz_no(out_biz_no, update_data)
  853. @classmethod
  854. async def consume_detail_query_service(
  855. cls,
  856. auth: AuthSchema,
  857. pay_no: str,
  858. enterprise_id: str | None = None,
  859. ant_shop_id: str | None = None,
  860. query_options: list[str] | None = None,
  861. ) -> dict:
  862. """
  863. 账单详情查询(✅)
  864. 调用: alipay.commerce.ec.consume.detail.query
  865. 用于查询企业码账单详情,支持查询关联退款、订单、票据等信息。
  866. """
  867. from alipay.aop.api.request.AlipayCommerceEcConsumeDetailQueryRequest import (
  868. AlipayCommerceEcConsumeDetailQueryRequest,
  869. )
  870. from alipay.aop.api.domain.AlipayCommerceEcConsumeDetailQueryModel import (
  871. AlipayCommerceEcConsumeDetailQueryModel,
  872. )
  873. from alipay.aop.api.response.AlipayCommerceEcConsumeDetailQueryResponse import (
  874. AlipayCommerceEcConsumeDetailQueryResponse,
  875. )
  876. model = AlipayCommerceEcConsumeDetailQueryModel()
  877. model.pay_no = pay_no
  878. if enterprise_id:
  879. model.enterprise_id = enterprise_id
  880. if ant_shop_id:
  881. model.ant_shop_id = ant_shop_id
  882. if query_options:
  883. model.query_options = query_options
  884. request = AlipayCommerceEcConsumeDetailQueryRequest()
  885. request.biz_model = model
  886. client = AlipayClient.get_client()
  887. response = client.execute(request)
  888. if not response:
  889. raise CustomException(msg="账单详情查询失败: 无响应")
  890. result = AlipayCommerceEcConsumeDetailQueryResponse()
  891. result.parse_response_content(response)
  892. if not result.is_success():
  893. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  894. raise CustomException(msg=f"账单详情查询失败: {result.msg}")
  895. consume_info = result.consume_info
  896. if not consume_info:
  897. raise CustomException(msg="账单详情查询失败: 无账单信息")
  898. return {
  899. "account_id": consume_info.account_id,
  900. "pay_no": consume_info.pay_no,
  901. "consume_type": consume_info.consume_type,
  902. "gmt_biz_create": consume_info.gmt_biz_create,
  903. "consume_biz_type": consume_info.consume_biz_type,
  904. "consume_amount": consume_info.consume_amount,
  905. "order_complete_label": consume_info.order_complete_label,
  906. "refund_status": consume_info.refund_status,
  907. "refund_amount": consume_info.refund_amount,
  908. "peer_payer_card_name": consume_info.peer_payer_card_name,
  909. "user_id": getattr(consume_info, 'user_id', None),
  910. "open_id": getattr(consume_info, 'open_id', None),
  911. "enterprise_id": consume_info.enterprise_id,
  912. "employee_id": consume_info.employee_id,
  913. "enterprise_name": getattr(consume_info, 'enterprise_name', None),
  914. "employee_name": getattr(consume_info, 'employee_name', None),
  915. "consume_scene_code": getattr(consume_info, 'consume_scene_code', None),
  916. "consume_type_sub_category": getattr(consume_info, 'consume_type_sub_category', None),
  917. "consume_title": getattr(consume_info, 'consume_title', None),
  918. "gmt_pay": getattr(consume_info, 'gmt_pay', None),
  919. "gmt_refund": getattr(consume_info, 'gmt_refund', None),
  920. "pay_amount": getattr(consume_info, 'pay_amount', None),
  921. "invoice_amount": getattr(consume_info, 'invoice_amount', None),
  922. "peer_pay_amount": getattr(consume_info, 'peer_pay_amount', None),
  923. "subsidy_amount": getattr(consume_info, 'subsidy_amount', None),
  924. "ext_infos": getattr(consume_info, 'ext_infos', None),
  925. }