service.py 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375
  1. from datetime import datetime, timedelta
  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. # 支付宝资金专户转账错误码 → 友好提示
  35. _TRANSFER_ERROR_HINTS = {
  36. "SYSTEM_ERROR": "系统繁忙,请稍后重试",
  37. "INVALID_PARAMETER": "请求参数有误,请检查后重试",
  38. "AMOUNT_LESS_THAN_ONE_CENT": "转账金额不能低于 0.01 元",
  39. "BALANCE_IS_NOT_ENOUGH": "企业余额不足,建议充值",
  40. "BANK_RESPONSE_ERROR": "银行处理失败:账户异常",
  41. "CARD_BIN_ERROR": "收款银行账号不正确,请确认",
  42. "DUPLICATE_DIFFERENT_REQUEST": "重复请求但参数不一致,请检查",
  43. "EXCEED_LIMIT_SM_MIN_AMOUNT": "转账金额不能低于 0.1 元",
  44. "EXCEED_LIMIT_DM_MAX_AMOUNT": "超出单日转账限额,请明天再试或联系管理员提升限额",
  45. "INVALID_ACCOUNT_BOOK": "资金专户不存在,请检查专户号",
  46. "INVALID_CARDNO": "无效的收款银行卡号",
  47. "INVALID_IDENTITY_TYPE": "收款方身份类型不匹配",
  48. "NO_AGREEMENT": "无转账权限,请联系管理员",
  49. "PAYEE_CARD_INFO_ERROR": "收款方账号或银行卡信息有误,请核实",
  50. "PAYEE_NOT_EXIST": "收款账号不存在或姓名有误",
  51. "PAYER_BALANCE_NOT_ENOUGH": "付款方余额不足,建议充值",
  52. "REQUEST_PROCESSING": "系统处理中,请稍后重试",
  53. "TRANS_AUTH_NO_EXIST": "转账授权协议不存在,请先签约",
  54. }
  55. def _parse_dt(val: str | None) -> datetime | None:
  56. """解析支付宝日期字符串"""
  57. if not val:
  58. return None
  59. for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
  60. try:
  61. return datetime.strptime(val, fmt)
  62. except ValueError:
  63. continue
  64. return None
  65. class AccountService:
  66. """资金专户服务层"""
  67. @classmethod
  68. async def stat_transfer_amount_service(
  69. cls,
  70. auth: AuthSchema,
  71. tenant_id: Optional[int] = None,
  72. enterprise_id: Optional[str] = None,
  73. start_date: Optional[datetime] = None,
  74. end_date: Optional[datetime] = None,
  75. payee_type: Optional[str] = None,
  76. ) -> Decimal:
  77. """
  78. 统计转账金额(✅)
  79. 统计企业在指定时间范围内的转账总金额以及每天的转账金额。
  80. """
  81. crud = TransferCRUD(auth)
  82. return await crud.get_transfer_amount(
  83. tenant_id=tenant_id,
  84. enterprise_id=enterprise_id,
  85. start_date=start_date,
  86. end_date=end_date,
  87. payee_type=payee_type,
  88. )
  89. @classmethod
  90. async def stat_consume_amount_service(
  91. cls,
  92. auth: AuthSchema,
  93. tenant_id: Optional[int] = None,
  94. enterprise_id: Optional[str] = None,
  95. start_date: Optional[datetime] = None,
  96. end_date: Optional[datetime] = None,
  97. ) -> Decimal:
  98. """
  99. 统计消费金额(✅)
  100. 统计企业在指定时间范围内的消费总金额。
  101. 数据来源: pay_bill 表,consume_type=CONSUME, status=PROCESSED
  102. """
  103. from app.plugin.module_payment.notification.crud import BillCRUD
  104. crud = BillCRUD(auth)
  105. return await crud.get_consume_amount(
  106. tenant_id=tenant_id,
  107. enterprise_id=enterprise_id,
  108. start_date=start_date,
  109. end_date=end_date,
  110. )
  111. @classmethod
  112. async def stat_summary_amount_service(
  113. cls,
  114. auth: AuthSchema,
  115. tenant_id: Optional[int] = None,
  116. enterprise_id: Optional[str] = None,
  117. start_date: Optional[datetime] = None,
  118. end_date: Optional[datetime] = None,
  119. payee_type: Optional[str] = None,
  120. ) -> Decimal:
  121. """
  122. 汇总统计金额(✅)
  123. 汇总 = 消费统计 + 转账统计,对应时间段结果相加
  124. payee_type 仅过滤转账部分
  125. """
  126. transfer_amount = await cls.stat_transfer_amount_service(
  127. auth=auth,
  128. tenant_id=tenant_id,
  129. enterprise_id=enterprise_id,
  130. start_date=start_date,
  131. end_date=end_date,
  132. payee_type=payee_type,
  133. )
  134. consume_amount = await cls.stat_consume_amount_service(
  135. auth=auth,
  136. tenant_id=tenant_id,
  137. enterprise_id=enterprise_id,
  138. start_date=start_date,
  139. end_date=end_date,
  140. )
  141. return transfer_amount + consume_amount
  142. @classmethod
  143. async def authorize_apply_service(
  144. cls,
  145. auth: AuthSchema,
  146. data: AccountAuthorizeApplySchema
  147. ) -> AccountAuthorizeApplyOutSchema:
  148. """
  149. 申请转账授权签约(✅)
  150. 调用: alipay.commerce.ec.trans.authorize.apply
  151. """
  152. from alipay.aop.api.request.AlipayCommerceEcTransAuthorizeApplyRequest import (
  153. AlipayCommerceEcTransAuthorizeApplyRequest,
  154. )
  155. from alipay.aop.api.domain.AlipayCommerceEcTransAuthorizeApplyModel import (
  156. AlipayCommerceEcTransAuthorizeApplyModel,
  157. )
  158. from alipay.aop.api.response.AlipayCommerceEcTransAuthorizeApplyResponse import (
  159. AlipayCommerceEcTransAuthorizeApplyResponse,
  160. )
  161. model = AlipayCommerceEcTransAuthorizeApplyModel()
  162. model.enterprise_id = data.enterprise_id
  163. request = AlipayCommerceEcTransAuthorizeApplyRequest()
  164. request.biz_model = model
  165. client = AlipayClient.get_client()
  166. response = client.execute(request)
  167. if not response:
  168. raise CustomException(msg="申请转账授权失败: 无响应")
  169. result = AlipayCommerceEcTransAuthorizeApplyResponse()
  170. result.parse_response_content(response)
  171. if not result.is_success():
  172. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  173. raise CustomException(msg=f"申请转账授权失败: {result.msg}")
  174. return AccountAuthorizeApplyOutSchema(
  175. sign_url=result.sign_url,
  176. )
  177. @classmethod
  178. async def create_account_service(
  179. cls,
  180. auth: AuthSchema,
  181. data: AccountCreateSchema
  182. ) -> AccountOperationOutSchema:
  183. """
  184. 开通资金专户(✅)
  185. 调用: alipay.commerce.ec.trans.account.create
  186. """
  187. from alipay.aop.api.request.AlipayCommerceEcTransAccountCreateRequest import (
  188. AlipayCommerceEcTransAccountCreateRequest,
  189. )
  190. from alipay.aop.api.domain.AlipayCommerceEcTransAccountCreateModel import (
  191. AlipayCommerceEcTransAccountCreateModel,
  192. )
  193. from alipay.aop.api.response.AlipayCommerceEcTransAccountCreateResponse import (
  194. AlipayCommerceEcTransAccountCreateResponse,
  195. )
  196. model = AlipayCommerceEcTransAccountCreateModel()
  197. model.enterprise_id = data.enterprise_id
  198. # model.account_type = data.account_type or "ALL" # 收支全能户
  199. # model.scene = data.scene or "B2B_TRANS" # ToB转账场景
  200. model.account_type = "ALL"
  201. model.scene = "B2B_TRANS"
  202. request = AlipayCommerceEcTransAccountCreateRequest()
  203. request.biz_model = model
  204. client = AlipayClient.get_client()
  205. response = client.execute(request)
  206. if not response:
  207. raise CustomException(msg="开通资金专户失败: 无响应")
  208. result = AlipayCommerceEcTransAccountCreateResponse()
  209. result.parse_response_content(response)
  210. if not result.is_success():
  211. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  212. raise CustomException(msg=f"开通资金专户失败: {result.msg}")
  213. account_data = AccountCreateSchema(
  214. enterprise_id=model.enterprise_id,
  215. account_book_id=result.account_book_id,
  216. account_type=model.account_type,
  217. scene=model.scene,
  218. )
  219. if result.account_book_id:
  220. account_data.account_book_id = result.account_book_id
  221. await AccountCRUD(auth).create(account_data)
  222. return AccountOperationOutSchema(
  223. enterprise_id=account_data.enterprise_id,
  224. account_book_id=account_data.account_book_id,
  225. )
  226. @classmethod
  227. async def deposit_service(
  228. cls,
  229. auth: AuthSchema,
  230. data: AccountDepositSchema
  231. ) -> AccountDepositOutSchema:
  232. """
  233. 资金专户充值(✅)
  234. 调用: alipay.commerce.ec.trans.account.deposit
  235. """
  236. from alipay.aop.api.request.AlipayCommerceEcTransAccountDepositRequest import (
  237. AlipayCommerceEcTransAccountDepositRequest,
  238. )
  239. from alipay.aop.api.domain.AlipayCommerceEcTransAccountDepositModel import (
  240. AlipayCommerceEcTransAccountDepositModel,
  241. )
  242. from alipay.aop.api.response.AlipayCommerceEcTransAccountDepositResponse import (
  243. AlipayCommerceEcTransAccountDepositResponse,
  244. )
  245. model = AlipayCommerceEcTransAccountDepositModel()
  246. model.enterprise_id = data.enterprise_id
  247. model.account_book_id = data.account_book_id
  248. model.amount = str(data.amount)
  249. model.out_biz_no = get_snowflake_id_str(auth.tenant_id)
  250. request = AlipayCommerceEcTransAccountDepositRequest()
  251. request.biz_model = model
  252. client = AlipayClient.get_client()
  253. response = client.execute(request)
  254. if not response:
  255. raise CustomException(msg="充值失败: 无响应")
  256. result = AlipayCommerceEcTransAccountDepositResponse()
  257. result.parse_response_content(response)
  258. if not result.is_success():
  259. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  260. raise CustomException(msg=f"充值失败: {result.msg}")
  261. deposit_crud = DepositCRUD(auth)
  262. deposit_data = {
  263. "enterprise_id": data.enterprise_id,
  264. "out_biz_no": model.out_biz_no,
  265. "account_book_id": data.account_book_id,
  266. "amount": data.amount,
  267. "url": result.url,
  268. "status": DepositStatusEnum.DEALING.value,
  269. "remark": data.remark,
  270. }
  271. await deposit_crud.create(deposit_data)
  272. return AccountDepositOutSchema(
  273. url=result.url,
  274. )
  275. @classmethod
  276. async def transfer_service(
  277. cls,
  278. auth: AuthSchema,
  279. data: AccountTransferSchema
  280. ) -> AccountTransferOutSchema:
  281. """
  282. 资金专户转账(✅)
  283. 调用: alipay.commerce.ec.trans.account.transfer
  284. """
  285. from alipay.aop.api.request.AlipayCommerceEcTransAccountTransferRequest import (
  286. AlipayCommerceEcTransAccountTransferRequest,
  287. )
  288. from alipay.aop.api.domain.AlipayCommerceEcTransAccountTransferModel import (
  289. AlipayCommerceEcTransAccountTransferModel,
  290. )
  291. from alipay.aop.api.response.AlipayCommerceEcTransAccountTransferResponse import (
  292. AlipayCommerceEcTransAccountTransferResponse,
  293. )
  294. from alipay.aop.api.domain.TransParticipant import (
  295. TransParticipant,
  296. )
  297. from alipay.aop.api.domain.BankCardExtInfoDTO import (
  298. BankCardExtInfoDTO,
  299. )
  300. # 检查资金专户是否存在
  301. account = await AccountCRUD(auth).get_by_account_book_id(data.account_book_id)
  302. if not account:
  303. raise CustomException(msg="资金账户不存在")
  304. if account.tenant_id != auth.tenant_id:
  305. raise CustomException(msg="无权限操作")
  306. if data.enterprise_id and account.enterprise_id != data.enterprise_id:
  307. raise CustomException(msg="参数错误")
  308. if not data.order_title and account.enterprise_id:
  309. enterprise = await EnterpriseCRUD(auth).get_by_enterprise_id(account.enterprise_id)
  310. if not enterprise:
  311. raise CustomException(msg="资金账户所属企业不存在")
  312. data.order_title = f"来自{enterprise.name}转账"
  313. model = AlipayCommerceEcTransAccountTransferModel()
  314. model.enterprise_id = account.enterprise_id
  315. model.account_book_id = account.account_book_id
  316. model.out_biz_no = get_snowflake_id_str(auth.tenant_id)
  317. # 转账总金额,单位为元,精确到小数点后两位
  318. model.amount = str(data.amount)
  319. model.order_title = data.order_title
  320. payee_info = TransParticipant()
  321. payee_info.identity_type = data.payee_info.identity_type
  322. payee_info.name = data.payee_info.name
  323. payee_info.identity = data.payee_info.identity
  324. if data.payee_info.bankcard_ext_info:
  325. payee_info.bankcard_ext_info = BankCardExtInfoDTO.from_alipay_dict(
  326. data.payee_info.bankcard_ext_info.model_dump(exclude_none=True)
  327. )
  328. model.payee_info = payee_info
  329. request = AlipayCommerceEcTransAccountTransferRequest()
  330. request.biz_model = model
  331. client = AlipayClient.get_client()
  332. response = client.execute(request)
  333. if not response:
  334. raise CustomException(msg="转账失败: 无响应")
  335. result = AlipayCommerceEcTransAccountTransferResponse()
  336. result.parse_response_content(response)
  337. sub_code = getattr(result, 'sub_code', '') or ''
  338. sub_msg = getattr(result, 'sub_msg', '') or ''
  339. # 构建转账记录数据,但延迟写入:
  340. # - 成功时在当前会话写入
  341. # - 失败时使用独立事务写入并提交,避免被外层回滚吞掉
  342. transfer_crud = TransferCRUD(auth)
  343. transfer_data = {
  344. "enterprise_id": model.enterprise_id,
  345. "out_biz_no": model.out_biz_no,
  346. "account_book_id": model.account_book_id,
  347. "amount": model.amount,
  348. "order_title": model.order_title,
  349. "payee_info": data.payee_info.model_dump() if data.payee_info else None,
  350. "payee_type": data.payee_info.identity_type if data.payee_info else None,
  351. "status": result.status,
  352. "order_no": result.order_no,
  353. "fund_order_id": result.fund_order_id,
  354. "remark": "",
  355. }
  356. if result.status == TransferStatusEnum.DEALING.value:
  357. transfer_data["retry_count"] = 0
  358. transfer_data["next_retry_at"] = datetime.now() + timedelta(minutes=5)
  359. log.info(f"记录转账: {transfer_data}")
  360. if not result.is_success():
  361. # 优先用 sub_code 匹配
  362. hint = _TRANSFER_ERROR_HINTS.get(sub_code)
  363. # sub_code 无匹配时,尝试从 sub_msg 中提取错误码(支付宝部分接口sub_code返回unknown-sub-code)
  364. if not hint:
  365. for code_key, code_hint in _TRANSFER_ERROR_HINTS.items():
  366. if code_key in sub_msg:
  367. hint = code_hint
  368. break
  369. hint = hint or sub_msg or result.msg or "转账失败"
  370. log.error(f"支付宝接口调用失败: {result.code} - {result.msg} (sub_code={sub_code}, sub_msg={sub_msg})")
  371. # 使用独立的 session/事务保证失败记录能被持久化
  372. from app.core.database import async_db_session
  373. async with async_db_session() as _session:
  374. async with _session.begin():
  375. new_auth = AuthSchema(db=_session, check_data_scope=False)
  376. # 保持 tenant_id
  377. new_auth.tenant_id = getattr(auth, "tenant_id", None)
  378. transfer_data["status"]="FAIL"
  379. transfer_data["remark"]=f"{result.msg} ({sub_code} {sub_msg})"
  380. await TransferCRUD(new_auth).create(transfer_data)
  381. raise CustomException(msg=f"转账失败: {hint}")
  382. # 成功时写入当前会话
  383. await transfer_crud.create(transfer_data)
  384. return AccountTransferOutSchema(
  385. status=result.status,
  386. order_no=result.order_no,
  387. fund_order_id=result.fund_order_id,
  388. out_biz_no=model.out_biz_no,
  389. )
  390. @classmethod
  391. async def tenant_transfer_service(
  392. cls,
  393. auth: AuthSchema,
  394. tenant_id: int,
  395. data: TenantTransferCreate,
  396. request_ip: str,
  397. api_key_id: int | None = None,
  398. ) -> TenantTransferResponse:
  399. """
  400. 租户API转账(通过API Key认证)
  401. 调用: alipay.commerce.ec.trans.account.transfer
  402. """
  403. from alipay.aop.api.request.AlipayCommerceEcTransAccountTransferRequest import (
  404. AlipayCommerceEcTransAccountTransferRequest,
  405. )
  406. from alipay.aop.api.domain.AlipayCommerceEcTransAccountTransferModel import (
  407. AlipayCommerceEcTransAccountTransferModel,
  408. )
  409. from alipay.aop.api.response.AlipayCommerceEcTransAccountTransferResponse import (
  410. AlipayCommerceEcTransAccountTransferResponse,
  411. )
  412. from alipay.aop.api.domain.TransParticipant import (
  413. TransParticipant,
  414. )
  415. from alipay.aop.api.domain.BankCardExtInfoDTO import (
  416. BankCardExtInfoDTO,
  417. )
  418. # 检查资金专户是否存在
  419. account = await AccountCRUD(auth).get_by_account_book_id(data.account_book_id)
  420. if not account:
  421. raise CustomException(msg="资金账户不存在")
  422. if account.tenant_id != tenant_id:
  423. raise CustomException(msg="无权限操作")
  424. if data.enterprise_id and account.enterprise_id != data.enterprise_id:
  425. raise CustomException(msg="参数错误")
  426. if not data.order_title and account.enterprise_id:
  427. enterprise = await EnterpriseCRUD(auth).get_by_enterprise_id(account.enterprise_id)
  428. if not enterprise:
  429. raise CustomException(msg="资金账户所属企业不存在")
  430. data.order_title = f"来自{enterprise.name}转账"
  431. model = AlipayCommerceEcTransAccountTransferModel()
  432. model.enterprise_id = account.enterprise_id
  433. model.account_book_id = account.account_book_id
  434. model.out_biz_no = get_snowflake_id_str(tenant_id)
  435. # 转账总金额,单位为元,精确到小数点后两位
  436. model.amount = str(data.amount)
  437. model.order_title = data.order_title
  438. payee_info = TransParticipant()
  439. payee_info.identity_type = data.payee_info.identity_type
  440. payee_info.name = data.payee_info.name
  441. payee_info.identity = data.payee_info.identity
  442. if data.payee_info.bankcard_ext_info:
  443. payee_info.bankcard_ext_info = BankCardExtInfoDTO.from_alipay_dict(
  444. data.payee_info.bankcard_ext_info.model_dump(exclude_none=True)
  445. )
  446. model.payee_info = payee_info
  447. request = AlipayCommerceEcTransAccountTransferRequest()
  448. request.biz_model = model
  449. client = AlipayClient.get_client()
  450. response = client.execute(request)
  451. if not response:
  452. raise CustomException(msg="转账失败: 无响应")
  453. result = AlipayCommerceEcTransAccountTransferResponse()
  454. result.parse_response_content(response)
  455. if not result.is_success():
  456. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  457. raise CustomException(msg=f"转账失败: {result.sub_msg or result.msg or result.code}")
  458. transfer_crud = TransferCRUD(auth)
  459. transfer_data = {
  460. "enterprise_id": model.enterprise_id,
  461. "out_biz_no": model.out_biz_no,
  462. "account_book_id": model.account_book_id,
  463. "amount": model.amount,
  464. "order_title": model.order_title,
  465. "payee_info": data.payee_info.model_dump() if data.payee_info else None,
  466. "payee_type": data.payee_info.identity_type if data.payee_info else None,
  467. "status": result.status,
  468. "order_no": result.order_no,
  469. "fund_order_id": result.fund_order_id,
  470. }
  471. if result.status == TransferStatusEnum.DEALING.value:
  472. transfer_data["retry_count"] = 0
  473. transfer_data["next_retry_at"] = datetime.now() + timedelta(minutes=5)
  474. await transfer_crud.create(transfer_data)
  475. return TenantTransferResponse(
  476. status=result.status,
  477. order_no=result.order_no,
  478. fund_order_id=result.fund_order_id,
  479. )
  480. @classmethod
  481. async def withdraw_service(
  482. cls,
  483. auth: AuthSchema,
  484. data: AccountWithdrawSchema
  485. ) -> AccountOperationOutSchema:
  486. """
  487. 资金专户提现
  488. 调用: alipay.commerce.ec.trans.account.withdraw
  489. 接口文档: https://opendocs.alipay.com/pre-open/d651859b_alipay.commerce.ec.trans.account.withdraw
  490. 参数说明:
  491. - enterprise_id: 企业ID
  492. - account_book_id: 资金专户号
  493. - amount: 提现金额
  494. - out_biz_no: 商家侧订单号(唯一)
  495. """
  496. from alipay.aop.api.request.AlipayCommerceEcTransAccountWithdrawRequest import (
  497. AlipayCommerceEcTransAccountWithdrawRequest,
  498. )
  499. from alipay.aop.api.domain.AlipayCommerceEcTransAccountWithdrawModel import (
  500. AlipayCommerceEcTransAccountWithdrawModel,
  501. )
  502. from alipay.aop.api.response.AlipayCommerceEcTransAccountWithdrawResponse import (
  503. AlipayCommerceEcTransAccountWithdrawResponse,
  504. )
  505. crud = AccountCRUD(auth)
  506. enterprise = await crud.get_by_enterprise_id(data.enterprise_id)
  507. if not enterprise:
  508. raise CustomException(msg="企业不存在")
  509. model = AlipayCommerceEcTransAccountWithdrawModel()
  510. model.enterprise_id = enterprise.enterprise_id
  511. model.account_book_id = data.account_book_id
  512. model.amount = str(data.amount)
  513. model.out_biz_no = get_snowflake_id_str(auth.tenant_id)
  514. if data.remark:
  515. model.remark = data.remark
  516. request = AlipayCommerceEcTransAccountWithdrawRequest()
  517. request.biz_model = model
  518. client = AlipayClient.get_client()
  519. response = client.execute(request)
  520. if not response:
  521. raise CustomException(msg="提现失败: 无响应")
  522. result = AlipayCommerceEcTransAccountWithdrawResponse()
  523. result.parse_response_content(response)
  524. if not result.is_success():
  525. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  526. raise CustomException(msg=f"提现失败: {result.msg}")
  527. withdraw_crud = WithdrawCRUD(auth)
  528. withdraw_data = {
  529. "enterprise_id": data.enterprise_id,
  530. "out_biz_no": model.out_biz_no,
  531. "account_book_id": data.account_book_id,
  532. "amount": data.amount,
  533. # 专户提现到余额户是同步操作,要么执行成功,要么执行异常,
  534. # 出参status设计多余,遵循规范使用业务码区分成功与失败
  535. "status": WithdrawStatusEnum.SUCCESS.value,
  536. "order_no": result.order_no,
  537. }
  538. await withdraw_crud.create(withdraw_data)
  539. log.info(f"资金专户提现发起成功: 企业: {data.enterprise_id}, 金额: {data.amount}")
  540. return AccountOperationOutSchema(
  541. enterprise_id=data.enterprise_id,
  542. account_book_id=data.account_book_id,
  543. )
  544. @classmethod
  545. async def query_account_service(
  546. cls,
  547. auth: AuthSchema,
  548. data: AccountQuerySchema
  549. ) -> list[Any]:
  550. """
  551. 查询资金专户(调用支付宝接口)
  552. 调用: alipay.commerce.ec.trans.account.query
  553. """
  554. from alipay.aop.api.request.AlipayCommerceEcTransAccountQueryRequest import (
  555. AlipayCommerceEcTransAccountQueryRequest,
  556. )
  557. from alipay.aop.api.domain.AlipayCommerceEcTransAccountQueryModel import (
  558. AlipayCommerceEcTransAccountQueryModel,
  559. )
  560. from alipay.aop.api.response.AlipayCommerceEcTransAccountQueryResponse import (
  561. AlipayCommerceEcTransAccountQueryResponse,
  562. )
  563. from alipay.aop.api.domain.FundAccountApiDTO import (
  564. FundAccountApiDTO,
  565. )
  566. model = AlipayCommerceEcTransAccountQueryModel()
  567. model.enterprise_id = data.enterprise_id
  568. request = AlipayCommerceEcTransAccountQueryRequest()
  569. request.biz_model = model
  570. client = AlipayClient.get_client()
  571. response = client.execute(request)
  572. if not response:
  573. raise CustomException(msg="查询资金专户失败: 无响应")
  574. result = AlipayCommerceEcTransAccountQueryResponse()
  575. result.parse_response_content(response)
  576. if not result.is_success():
  577. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  578. raise CustomException(msg=f"查询资金专户失败: {result.msg}")
  579. collect = []
  580. for v in list(result.account_list or []):
  581. if not hasattr(v, "account_book_id"):
  582. continue
  583. if not hasattr(v, "scene") or v.scene != "B2B_TRANS":
  584. continue
  585. account = FundAccountApiDTO.to_alipay_dict(v)
  586. collect.append(account)
  587. return collect
  588. @classmethod
  589. async def transfer_detail_service(
  590. cls,
  591. auth: AuthSchema,
  592. out_biz_no: str
  593. ) -> TransferOutSchema:
  594. """
  595. 查询转账记录详情
  596. """
  597. crud = TransferCRUD(auth)
  598. transfer = await crud.get_by_out_biz_no(out_biz_no)
  599. if not transfer:
  600. raise CustomException(msg="转账记录不存在")
  601. transfer_result = TransferOutSchema.model_validate(transfer)
  602. # 查询三方订单号
  603. open_transfer_crud = OpenTransferCRUD(auth)
  604. open_transfer_data = await open_transfer_crud.get(out_biz_no=transfer.out_biz_no)
  605. if open_transfer_data:
  606. transfer_result.third_biz_no = open_transfer_data.third_biz_no
  607. return transfer_result
  608. @classmethod
  609. async def transfer_list_service(
  610. cls,
  611. auth: AuthSchema,
  612. page_no: int = 1,
  613. page_size: int = 20,
  614. search: dict | None = None,
  615. ) -> dict:
  616. """
  617. 查询转账记录列表
  618. """
  619. log.info(f"查询转账记录列表: {page_no}, {page_size}, {search}")
  620. crud = TransferCRUD(auth)
  621. offset = (page_no - 1) * page_size
  622. return await crud.page(
  623. offset=offset,
  624. limit=page_size,
  625. order_by=[{"id": "desc"}],
  626. search=search or {},
  627. out_schema=TransferListOutSchema,
  628. )
  629. @classmethod
  630. async def transfer_export_service(
  631. cls,
  632. auth: AuthSchema,
  633. start_time: str,
  634. end_time: str,
  635. enterprise_id: Optional[str] = None,
  636. ) -> bytes:
  637. """
  638. 导出转账记录报表为Excel文件
  639. """
  640. log.info(f"导出转账记录报表: {start_time} -> {end_time}")
  641. crud = TransferCRUD(auth)
  642. search = {
  643. "created_time__gte": start_time,
  644. "created_time__lte": end_time,
  645. }
  646. if enterprise_id:
  647. search["enterprise_id"] = enterprise_id
  648. records = await crud.list(
  649. search=search,
  650. order_by=[{"id": "desc"}],
  651. )
  652. from app.utils.excel_util import ExcelUtil
  653. status_map = {
  654. "DEALING": "处理中",
  655. "SUCCESS": "成功",
  656. "FAIL": "失败",
  657. "REFUND": "退票",
  658. }
  659. payee_type_map = {
  660. "ALIPAY_ACCOUNT": "支付宝账户",
  661. "BANK_CARD": "银行卡",
  662. }
  663. list_data = []
  664. for i, record in enumerate(records, start=1):
  665. payee_info = record.payee_info or {}
  666. list_data.append({
  667. "序号": i,
  668. "订单号": record.out_biz_no or "",
  669. "商户订单号": record.order_no or "",
  670. "金额(元)": str(record.amount or 0),
  671. "收款方姓名": payee_info.get("name", ""),
  672. "收款方类型": payee_type_map.get(payee_info.get("identity_type", ""), ""),
  673. "状态": status_map.get(record.status, record.status),
  674. "转账标题": record.order_title or "",
  675. "创建时间": record.created_time.strftime("%Y-%m-%d %H:%M:%S") if record.created_time else "",
  676. })
  677. mapping_dict = {
  678. "序号": "序号",
  679. "订单号": "订单号",
  680. "商户订单号": "商户订单号",
  681. "金额(元)": "金额(元)",
  682. "收款方姓名": "收款方姓名",
  683. "收款方类型": "收款方类型",
  684. "状态": "状态",
  685. "转账标题": "转账标题",
  686. "创建时间": "创建时间",
  687. }
  688. return ExcelUtil.export_list2excel(list_data, mapping_dict)
  689. @classmethod
  690. async def apply_receipt_service(
  691. cls,
  692. auth: AuthSchema,
  693. redis: Redis,
  694. data: ReceiptApplySchema,
  695. ) -> str:
  696. """
  697. 申请转账业务回单
  698. 调用: alipay.commerce.ec.trans.receipt.apply
  699. 参数:
  700. - enterprise_id: 企业ID
  701. - order_no: 支付宝转账单号
  702. 返回: file_id
  703. """
  704. from app.core.redis_crud import RedisCURD
  705. redis_crud = RedisCURD(redis)
  706. cache_key = f"receipt:{data.enterprise_id}:{data.order_no}"
  707. cached_file_id = await redis_crud.get(cache_key)
  708. if cached_file_id:
  709. log.info(f"使用缓存的 file_id: {cached_file_id}")
  710. return cached_file_id
  711. crud = EnterpriseCRUD(auth)
  712. enterprise = await crud.get_by_enterprise_id(data.enterprise_id)
  713. if not enterprise:
  714. raise CustomException(msg="企业不存在")
  715. from alipay.aop.api.request.AlipayCommerceEcTransReceiptApplyRequest import (
  716. AlipayCommerceEcTransReceiptApplyRequest,
  717. )
  718. from alipay.aop.api.domain.AlipayCommerceEcTransReceiptApplyModel import (
  719. AlipayCommerceEcTransReceiptApplyModel,
  720. )
  721. from alipay.aop.api.response.AlipayCommerceEcTransReceiptApplyResponse import (
  722. AlipayCommerceEcTransReceiptApplyResponse,
  723. )
  724. model = AlipayCommerceEcTransReceiptApplyModel()
  725. model.enterprise_id = data.enterprise_id
  726. model.order_no = data.order_no
  727. request = AlipayCommerceEcTransReceiptApplyRequest()
  728. request.biz_model = model
  729. client = AlipayClient.get_client()
  730. response = client.execute(request)
  731. if not response:
  732. raise CustomException(msg="申请回单失败: 无响应")
  733. result = AlipayCommerceEcTransReceiptApplyResponse()
  734. result.parse_response_content(response)
  735. if not result.is_success():
  736. # 清除缓存
  737. await redis_crud.delete(cache_key)
  738. raise CustomException(msg=f"申请回单失败: {result.msg}")
  739. file_id = str(result.file_id)
  740. await redis_crud.set(cache_key, file_id, expire=172800)
  741. log.info(f"申请回单成功: order_no={data.order_no}, file_id={file_id}")
  742. return file_id
  743. @classmethod
  744. async def query_receipt_service(cls, enterprise_id: str, file_id: str) -> dict:
  745. """
  746. 查询回单状态
  747. 调用: alipay.commerce.ec.trans.receipt.query
  748. 参数:
  749. - file_id: 文件申请号
  750. 返回: {file_id, status, download_url, error_message}
  751. """
  752. from alipay.aop.api.request.AlipayCommerceEcTransReceiptQueryRequest import (
  753. AlipayCommerceEcTransReceiptQueryRequest,
  754. )
  755. from alipay.aop.api.response.AlipayCommerceEcTransReceiptQueryResponse import (
  756. AlipayCommerceEcTransReceiptQueryResponse,
  757. )
  758. from alipay.aop.api.domain.AlipayCommerceEcTransReceiptQueryModel import (
  759. AlipayCommerceEcTransReceiptQueryModel,
  760. )
  761. model = AlipayCommerceEcTransReceiptQueryModel()
  762. model.enterprise_id = enterprise_id
  763. model.file_id = file_id
  764. request = AlipayCommerceEcTransReceiptQueryRequest()
  765. request.biz_model = model
  766. client = AlipayClient.get_client()
  767. response = client.execute(request)
  768. if not response:
  769. raise CustomException(msg="查询回单失败: 无响应")
  770. result = AlipayCommerceEcTransReceiptQueryResponse()
  771. result.parse_response_content(response)
  772. if not result.is_success():
  773. raise CustomException(msg=f"查询回单失败: {result.msg}")
  774. data = {
  775. "file_id": file_id,
  776. "status": result.status,
  777. "download_url": result.download_url,
  778. "error_message": result.error_message,
  779. }
  780. return data
  781. @classmethod
  782. async def transfer_sync_status_service(
  783. cls,
  784. auth: AuthSchema,
  785. data: "TransferSyncStatusSchema",
  786. ) -> dict:
  787. """
  788. 手动同步转账状态(管理员补录)
  789. 用于修复因通知丢失而卡在 DEALING 的转账记录
  790. """
  791. from app.plugin.module_payment.account.crud import TransferCRUD
  792. from app.plugin.module_payment.account.schema import TransferSyncStatusSchema
  793. crud = TransferCRUD(auth)
  794. transfer = await crud.get_by_out_biz_no(data.out_biz_no)
  795. if not transfer:
  796. raise CustomException(msg=f"转账记录不存在: {data.out_biz_no}")
  797. if transfer.status != "DEALING" and data.status == "SUCCESS":
  798. raise CustomException(msg=f"转账记录当前状态为 {transfer.status},无需同步")
  799. update_data = {"status": data.status}
  800. if data.error_code:
  801. update_data["error_code"] = data.error_code
  802. if data.error_msg:
  803. update_data["error_msg"] = data.error_msg
  804. for key, value in update_data.items():
  805. if hasattr(transfer, key):
  806. setattr(transfer, key, value)
  807. await auth.db.flush()
  808. await auth.db.refresh(transfer)
  809. log.info(f"手动同步转账状态成功: out_biz_no={data.out_biz_no}, {transfer.status}")
  810. return {
  811. "out_biz_no": transfer.out_biz_no,
  812. "status": transfer.status,
  813. "error_code": transfer.error_code,
  814. "error_msg": transfer.error_msg,
  815. }
  816. @classmethod
  817. async def transfer_sync_all_service(
  818. cls,
  819. auth: AuthSchema,
  820. ) -> dict:
  821. """
  822. 全量同步转账状态(使用 trans.order.query)
  823. """
  824. from sqlalchemy import select
  825. from app.plugin.module_payment.account.model import TransferModel
  826. from app.plugin.module_payment.account.enums import TransferStatusEnum
  827. stmt = select(TransferModel).where(
  828. TransferModel.status == TransferStatusEnum.DEALING.value,
  829. ).order_by(TransferModel.id.asc())
  830. result = await auth.db.execute(stmt)
  831. all_transfers = list(result.scalars().all())
  832. synced = 0
  833. errors = 0
  834. details = []
  835. for transfer in all_transfers:
  836. out_biz_no = transfer.out_biz_no
  837. eid = transfer.enterprise_id
  838. order_no = transfer.order_no or ""
  839. if not out_biz_no or not eid:
  840. continue
  841. try:
  842. new_status = await cls._sync_transfer_detail(auth, out_biz_no, eid, order_no)
  843. if new_status:
  844. synced += 1
  845. details.append({"out_biz_no": out_biz_no, "old_status": transfer.status, "new_status": new_status})
  846. else:
  847. details.append({"out_biz_no": out_biz_no, "status": transfer.status, "action": "no_change"})
  848. except Exception as e:
  849. errors += 1
  850. details.append({"out_biz_no": out_biz_no, "status": transfer.status, "error": str(e)})
  851. log.warning(f"全量同步 - 查询失败: out_biz_no={out_biz_no}, err={e}")
  852. if synced > 0:
  853. await auth.db.flush()
  854. return {
  855. "total": len(all_transfers),
  856. "synced": synced,
  857. "errors": errors,
  858. "details": details,
  859. }
  860. @classmethod
  861. async def _sync_transfer_detail(
  862. cls,
  863. auth: AuthSchema,
  864. out_biz_no: str,
  865. enterprise_id: str,
  866. order_no: str = "",
  867. ) -> str | None:
  868. """调 alipay.commerce.ec.trans.order.query 查询单笔转账状态并更新
  869. 有 out_biz_no 传 out_biz_no,有 order_no 也传 order_no(两个都传)
  870. 返回: 新状态str / None(不可查/无变化)
  871. """
  872. from sqlalchemy import update as sa_update
  873. from app.plugin.module_payment.account.model import TransferModel
  874. from app.core.alipay import AlipayClient
  875. try:
  876. from alipay.aop.api.request.AlipayCommerceEcTransOrderQueryRequest import (
  877. AlipayCommerceEcTransOrderQueryRequest,
  878. )
  879. from alipay.aop.api.domain.AlipayCommerceEcTransOrderQueryModel import (
  880. AlipayCommerceEcTransOrderQueryModel,
  881. )
  882. from alipay.aop.api.response.AlipayCommerceEcTransOrderQueryResponse import (
  883. AlipayCommerceEcTransOrderQueryResponse,
  884. )
  885. except ImportError:
  886. log.warning("SDK 不支持 trans.order.query")
  887. return None
  888. model = AlipayCommerceEcTransOrderQueryModel()
  889. model.enterprise_id = enterprise_id
  890. if out_biz_no:
  891. model.out_biz_no = out_biz_no
  892. if order_no:
  893. model.order_no = order_no
  894. request = AlipayCommerceEcTransOrderQueryRequest()
  895. request.biz_model = model
  896. client = AlipayClient.get_client()
  897. response = client.execute(request)
  898. if not response:
  899. return None
  900. result = AlipayCommerceEcTransOrderQueryResponse()
  901. result.parse_response_content(response)
  902. if not result.is_success():
  903. sub_code = getattr(result, 'sub_code', '') or ''
  904. sub_msg = getattr(result, 'sub_msg', '') or ''
  905. if sub_code == "ORDER_NOT_EXIST":
  906. return None
  907. log.warning(f"trans.order.query 失败: out_biz_no={out_biz_no}, sub_code={sub_code}, sub_msg={sub_msg}")
  908. return None
  909. alipay_status = getattr(result, 'status', None)
  910. if not alipay_status or alipay_status == "DEALING":
  911. return None
  912. update_data = {"status": alipay_status}
  913. api_order_no = getattr(result, 'order_no', None)
  914. api_amount = getattr(result, 'amount', None)
  915. api_fund_order_id = getattr(result, 'pay_fund_order_id', None)
  916. if api_order_no:
  917. update_data["order_no"] = api_order_no
  918. if api_fund_order_id:
  919. update_data["fund_order_id"] = api_fund_order_id
  920. if api_amount:
  921. update_data["amount"] = Decimal(str(api_amount))
  922. upd = sa_update(TransferModel).where(
  923. TransferModel.out_biz_no == out_biz_no
  924. ).values(**update_data)
  925. await auth.db.execute(upd)
  926. log.info(f"转账同步(trans.order.query) - out_biz_no={out_biz_no}, status={alipay_status}")
  927. return alipay_status
  928. # ─────────── 退避时间映射 ───────────
  929. _RETRY_INTERVALS = [5, 10, 30, 60] # 分钟: retry_count 0→1, 1→2, 2→3, 3 判 FAIL
  930. @classmethod
  931. async def retry_dealing_transfers(cls) -> dict:
  932. """定时任务:反查处理中的到卡转账结果(DB 锁防多 worker 重复执行)
  933. 启动时立即执行一次,之后每分钟执行一次。使用独立数据库会话。
  934. """
  935. from sqlalchemy import select, text
  936. from app.plugin.module_payment.account.model import TransferModel
  937. from app.plugin.module_payment.account.enums import TransferStatusEnum
  938. from app.core.database import async_db_session
  939. db = async_db_session()
  940. try:
  941. async with db.begin():
  942. # 抢 PostgreSQL advisory lock
  943. lock = await db.execute(text("SELECT pg_try_advisory_lock(99999)"))
  944. if not lock.scalar():
  945. log.debug("[重试任务] 锁已被其他 worker 持有,跳过")
  946. return {"scanned": 0, "processed": 0, "skipped_lock": True}
  947. try:
  948. log.info("[重试任务] ====== 开始执行 ======")
  949. now = datetime.now()
  950. stmt = (
  951. select(TransferModel)
  952. .where(
  953. TransferModel.status == TransferStatusEnum.DEALING.value,
  954. TransferModel.retry_count < 4,
  955. TransferModel.next_retry_at.isnot(None),
  956. TransferModel.next_retry_at <= now,
  957. )
  958. .order_by(TransferModel.next_retry_at.asc())
  959. )
  960. result = await db.execute(stmt)
  961. pending = list(result.scalars().all())
  962. if not pending:
  963. return {"scanned": 0, "processed": 0}
  964. log.info(f"[重试任务] 扫描到 {len(pending)} 条")
  965. processed = 0
  966. for transfer in pending:
  967. out_biz_no = transfer.out_biz_no
  968. enterprise_id = transfer.enterprise_id
  969. order_no = transfer.order_no or ""
  970. retry_count = transfer.retry_count
  971. try:
  972. sync_result = await cls._sync_transfer_detail(
  973. AuthSchema(db=db, check_data_scope=False, tenant_id=transfer.tenant_id),
  974. out_biz_no, enterprise_id, order_no,
  975. )
  976. except Exception as e:
  977. log.warning(f"[重试任务] trans.order.query 异常: out_biz_no={out_biz_no}, err={e}")
  978. continue
  979. if sync_result: # SUCCESS 或 FAIL
  980. transfer.retry_count = 4
  981. transfer.next_retry_at = None
  982. log.info(f"[重试任务] out_biz_no={out_biz_no}, retry={retry_count+1}/4, result={sync_result}, 已更新")
  983. try:
  984. auth = AuthSchema(db=db, check_data_scope=False, tenant_id=transfer.tenant_id)
  985. await OpenTransferService.open_return_service(auth, order_no or out_biz_no)
  986. except Exception:
  987. pass
  988. else:
  989. if retry_count < 3:
  990. accumulated = sum(cls._RETRY_INTERVALS[:retry_count + 2])
  991. transfer.retry_count = retry_count + 1
  992. transfer.next_retry_at = transfer.created_time + timedelta(minutes=accumulated)
  993. log.info(f"[重试任务] out_biz_no={out_biz_no}, retry={retry_count+1}/4, result=DEALING, 下次={transfer.next_retry_at}")
  994. processed += 1
  995. else:
  996. transfer.status = TransferStatusEnum.FAIL.value
  997. transfer.retry_count = 4
  998. transfer.next_retry_at = None
  999. transfer.error_msg = "反查超时判定为失败"
  1000. processed += 1
  1001. log.info(f"[重试任务] out_biz_no={out_biz_no}, retry=4/4次DEALING, 超时判FAIL")
  1002. try:
  1003. auth = AuthSchema(db=db, check_data_scope=False, tenant_id=transfer.tenant_id)
  1004. await OpenTransferService.open_return_service(auth, order_no or out_biz_no)
  1005. except Exception:
  1006. pass
  1007. # db.begin() 会自动 commit
  1008. log.info(f"[重试任务] ====== 执行完成 ====== 处理 {processed} 条,扫描 {len(pending)} 条")
  1009. return {"scanned": len(pending), "processed": processed}
  1010. finally:
  1011. await db.execute(text("SELECT pg_advisory_unlock(99999)"))
  1012. except Exception:
  1013. log.error("[重试任务] 执行异常", exc_info=True)
  1014. return {"scanned": 0, "processed": 0}
  1015. finally:
  1016. await db.close()
  1017. @classmethod
  1018. async def update_transfer_status_service(
  1019. cls,
  1020. auth: AuthSchema,
  1021. order_no: str,
  1022. status: str,
  1023. ext_info: dict = {}
  1024. ) -> None:
  1025. """
  1026. 更新转账状态(由通知处理器调用)
  1027. """
  1028. crud = TransferCRUD(auth)
  1029. transfer = await crud.get_by_order_no(order_no)
  1030. if not transfer:
  1031. log.warning(f"转账记录不存在: {order_no}")
  1032. return
  1033. update_data = {}
  1034. update_data["status"] = status
  1035. if ext_info:
  1036. update_data["ext_info"] = ext_info
  1037. await crud.update_by_order_no(order_no, update_data)
  1038. @classmethod
  1039. async def update_deposit_status_service(
  1040. cls,
  1041. auth: AuthSchema,
  1042. out_biz_no: str,
  1043. status: str,
  1044. ) -> None:
  1045. """
  1046. 更新充值状态(由通知处理器调用)
  1047. """
  1048. crud = DepositCRUD(auth)
  1049. deposit = await crud.get_by_out_biz_no(out_biz_no)
  1050. if not deposit:
  1051. log.warning(f"充值记录不存在: {out_biz_no}")
  1052. return
  1053. update_data = {"status": status}
  1054. await crud.update_by_out_biz_no(out_biz_no, update_data)
  1055. @classmethod
  1056. async def update_withdraw_status_service(
  1057. cls,
  1058. auth: AuthSchema,
  1059. out_biz_no: str,
  1060. status: str,
  1061. error_code: str | None = None,
  1062. error_msg: str | None = None,
  1063. ) -> None:
  1064. """
  1065. 更新提现状态(由通知处理器调用)
  1066. """
  1067. crud = WithdrawCRUD(auth)
  1068. withdraw = await crud.get_by_out_biz_no(out_biz_no)
  1069. if not withdraw:
  1070. log.warning(f"提现记录不存在: {out_biz_no}")
  1071. return
  1072. update_data = {"status": status}
  1073. if error_code:
  1074. update_data["error_code"] = error_code
  1075. if error_msg:
  1076. update_data["error_msg"] = error_msg
  1077. await crud.update_by_out_biz_no(out_biz_no, update_data)
  1078. @classmethod
  1079. async def consume_detail_query_service(
  1080. cls,
  1081. auth: AuthSchema,
  1082. pay_no: str,
  1083. enterprise_id: str | None = None,
  1084. ant_shop_id: str | None = None,
  1085. query_options: list[str] | None = None,
  1086. ) -> dict:
  1087. """
  1088. 账单详情查询(✅)
  1089. 调用: alipay.commerce.ec.consume.detail.query
  1090. 用于查询企业码账单详情,支持查询关联退款、订单、票据等信息。
  1091. """
  1092. from alipay.aop.api.request.AlipayCommerceEcConsumeDetailQueryRequest import (
  1093. AlipayCommerceEcConsumeDetailQueryRequest,
  1094. )
  1095. from alipay.aop.api.domain.AlipayCommerceEcConsumeDetailQueryModel import (
  1096. AlipayCommerceEcConsumeDetailQueryModel,
  1097. )
  1098. from alipay.aop.api.response.AlipayCommerceEcConsumeDetailQueryResponse import (
  1099. AlipayCommerceEcConsumeDetailQueryResponse,
  1100. )
  1101. model = AlipayCommerceEcConsumeDetailQueryModel()
  1102. model.pay_no = pay_no
  1103. if enterprise_id:
  1104. model.enterprise_id = enterprise_id
  1105. if ant_shop_id:
  1106. model.ant_shop_id = ant_shop_id
  1107. if query_options:
  1108. model.query_options = query_options
  1109. request = AlipayCommerceEcConsumeDetailQueryRequest()
  1110. request.biz_model = model
  1111. client = AlipayClient.get_client()
  1112. response = client.execute(request)
  1113. if not response:
  1114. raise CustomException(msg="账单详情查询失败: 无响应")
  1115. result = AlipayCommerceEcConsumeDetailQueryResponse()
  1116. result.parse_response_content(response)
  1117. if not result.is_success():
  1118. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  1119. raise CustomException(msg=f"账单详情查询失败: {result.msg}")
  1120. consume_info = result.consume_info
  1121. if not consume_info:
  1122. raise CustomException(msg="账单详情查询失败: 无账单信息")
  1123. return {
  1124. "account_id": consume_info.account_id,
  1125. "pay_no": consume_info.pay_no,
  1126. "consume_type": consume_info.consume_type,
  1127. "gmt_biz_create": consume_info.gmt_biz_create,
  1128. "consume_biz_type": consume_info.consume_biz_type,
  1129. "consume_amount": consume_info.consume_amount,
  1130. "order_complete_label": consume_info.order_complete_label,
  1131. "refund_status": consume_info.refund_status,
  1132. "refund_amount": consume_info.refund_amount,
  1133. "peer_payer_card_name": consume_info.peer_payer_card_name,
  1134. "user_id": getattr(consume_info, 'user_id', None),
  1135. "open_id": getattr(consume_info, 'open_id', None),
  1136. "enterprise_id": consume_info.enterprise_id,
  1137. "employee_id": consume_info.employee_id,
  1138. "enterprise_name": getattr(consume_info, 'enterprise_name', None),
  1139. "employee_name": getattr(consume_info, 'employee_name', None),
  1140. "consume_scene_code": getattr(consume_info, 'consume_scene_code', None),
  1141. "consume_type_sub_category": getattr(consume_info, 'consume_type_sub_category', None),
  1142. "consume_title": getattr(consume_info, 'consume_title', None),
  1143. "gmt_pay": getattr(consume_info, 'gmt_pay', None),
  1144. "gmt_refund": getattr(consume_info, 'gmt_refund', None),
  1145. "pay_amount": getattr(consume_info, 'pay_amount', None),
  1146. "invoice_amount": getattr(consume_info, 'invoice_amount', None),
  1147. "peer_pay_amount": getattr(consume_info, 'peer_pay_amount', None),
  1148. "subsidy_amount": getattr(consume_info, 'subsidy_amount', None),
  1149. "ext_infos": getattr(consume_info, 'ext_infos', None),
  1150. }