service.py 50 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377
  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. # 支付宝资金专户转账错误码 → 友好提示
  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. log.info(f"记录转账: {transfer_data}")
  357. if not result.is_success():
  358. # 优先用 sub_code 匹配
  359. hint = _TRANSFER_ERROR_HINTS.get(sub_code)
  360. # sub_code 无匹配时,尝试从 sub_msg 中提取错误码(支付宝部分接口sub_code返回unknown-sub-code)
  361. if not hint:
  362. for code_key, code_hint in _TRANSFER_ERROR_HINTS.items():
  363. if code_key in sub_msg:
  364. hint = code_hint
  365. break
  366. hint = hint or sub_msg or result.msg or "转账失败"
  367. log.error(f"支付宝接口调用失败: {result.code} - {result.msg} (sub_code={sub_code}, sub_msg={sub_msg})")
  368. # 使用独立的 session/事务保证失败记录能被持久化
  369. from app.core.database import async_db_session
  370. async with async_db_session() as _session:
  371. async with _session.begin():
  372. new_auth = AuthSchema(db=_session, check_data_scope=False)
  373. # 保持 tenant_id
  374. new_auth.tenant_id = getattr(auth, "tenant_id", None)
  375. transfer_data["status"]="FAIL"
  376. transfer_data["remark"]=f"{result.msg} ({sub_code} {sub_msg})"
  377. await TransferCRUD(new_auth).create(transfer_data)
  378. raise CustomException(msg=f"转账失败: {hint}")
  379. # 成功时写入当前会话
  380. await transfer_crud.create(transfer_data)
  381. return AccountTransferOutSchema(
  382. status=result.status,
  383. order_no=result.order_no,
  384. fund_order_id=result.fund_order_id,
  385. out_biz_no=model.out_biz_no,
  386. )
  387. @classmethod
  388. async def tenant_transfer_service(
  389. cls,
  390. auth: AuthSchema,
  391. tenant_id: int,
  392. data: TenantTransferCreate,
  393. request_ip: str,
  394. api_key_id: int | None = None,
  395. ) -> TenantTransferResponse:
  396. """
  397. 租户API转账(通过API Key认证)
  398. 调用: alipay.commerce.ec.trans.account.transfer
  399. """
  400. from alipay.aop.api.request.AlipayCommerceEcTransAccountTransferRequest import (
  401. AlipayCommerceEcTransAccountTransferRequest,
  402. )
  403. from alipay.aop.api.domain.AlipayCommerceEcTransAccountTransferModel import (
  404. AlipayCommerceEcTransAccountTransferModel,
  405. )
  406. from alipay.aop.api.response.AlipayCommerceEcTransAccountTransferResponse import (
  407. AlipayCommerceEcTransAccountTransferResponse,
  408. )
  409. from alipay.aop.api.domain.TransParticipant import (
  410. TransParticipant,
  411. )
  412. from alipay.aop.api.domain.BankCardExtInfoDTO import (
  413. BankCardExtInfoDTO,
  414. )
  415. # 检查资金专户是否存在
  416. account = await AccountCRUD(auth).get_by_account_book_id(data.account_book_id)
  417. if not account:
  418. raise CustomException(msg="资金账户不存在")
  419. if account.tenant_id != tenant_id:
  420. raise CustomException(msg="无权限操作")
  421. if data.enterprise_id and account.enterprise_id != data.enterprise_id:
  422. raise CustomException(msg="参数错误")
  423. if not data.order_title and account.enterprise_id:
  424. enterprise = await EnterpriseCRUD(auth).get_by_enterprise_id(account.enterprise_id)
  425. if not enterprise:
  426. raise CustomException(msg="资金账户所属企业不存在")
  427. data.order_title = f"来自{enterprise.name}转账"
  428. model = AlipayCommerceEcTransAccountTransferModel()
  429. model.enterprise_id = account.enterprise_id
  430. model.account_book_id = account.account_book_id
  431. model.out_biz_no = get_snowflake_id_str(tenant_id)
  432. # 转账总金额,单位为元,精确到小数点后两位
  433. model.amount = str(data.amount)
  434. model.order_title = data.order_title
  435. payee_info = TransParticipant()
  436. payee_info.identity_type = data.payee_info.identity_type
  437. payee_info.name = data.payee_info.name
  438. payee_info.identity = data.payee_info.identity
  439. if data.payee_info.bankcard_ext_info:
  440. payee_info.bankcard_ext_info = BankCardExtInfoDTO.from_alipay_dict(
  441. data.payee_info.bankcard_ext_info.model_dump(exclude_none=True)
  442. )
  443. model.payee_info = payee_info
  444. request = AlipayCommerceEcTransAccountTransferRequest()
  445. request.biz_model = model
  446. client = AlipayClient.get_client()
  447. response = client.execute(request)
  448. if not response:
  449. raise CustomException(msg="转账失败: 无响应")
  450. result = AlipayCommerceEcTransAccountTransferResponse()
  451. result.parse_response_content(response)
  452. if not result.is_success():
  453. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  454. raise CustomException(msg=f"转账失败: {result.sub_msg or result.msg or result.code}")
  455. transfer_crud = TransferCRUD(auth)
  456. transfer_data = {
  457. "enterprise_id": model.enterprise_id,
  458. "out_biz_no": model.out_biz_no,
  459. "account_book_id": model.account_book_id,
  460. "amount": model.amount,
  461. "order_title": model.order_title,
  462. "payee_info": data.payee_info.model_dump() if data.payee_info else None,
  463. "payee_type": data.payee_info.identity_type if data.payee_info else None,
  464. "status": result.status,
  465. "order_no": result.order_no,
  466. "fund_order_id": result.fund_order_id,
  467. }
  468. await transfer_crud.create(transfer_data)
  469. return TenantTransferResponse(
  470. status=result.status,
  471. order_no=result.order_no,
  472. fund_order_id=result.fund_order_id,
  473. )
  474. @classmethod
  475. async def withdraw_service(
  476. cls,
  477. auth: AuthSchema,
  478. data: AccountWithdrawSchema
  479. ) -> AccountOperationOutSchema:
  480. """
  481. 资金专户提现
  482. 调用: alipay.commerce.ec.trans.account.withdraw
  483. 接口文档: https://opendocs.alipay.com/pre-open/d651859b_alipay.commerce.ec.trans.account.withdraw
  484. 参数说明:
  485. - enterprise_id: 企业ID
  486. - account_book_id: 资金专户号
  487. - amount: 提现金额
  488. - out_biz_no: 商家侧订单号(唯一)
  489. """
  490. from alipay.aop.api.request.AlipayCommerceEcTransAccountWithdrawRequest import (
  491. AlipayCommerceEcTransAccountWithdrawRequest,
  492. )
  493. from alipay.aop.api.domain.AlipayCommerceEcTransAccountWithdrawModel import (
  494. AlipayCommerceEcTransAccountWithdrawModel,
  495. )
  496. from alipay.aop.api.response.AlipayCommerceEcTransAccountWithdrawResponse import (
  497. AlipayCommerceEcTransAccountWithdrawResponse,
  498. )
  499. crud = AccountCRUD(auth)
  500. enterprise = await crud.get_by_enterprise_id(data.enterprise_id)
  501. if not enterprise:
  502. raise CustomException(msg="企业不存在")
  503. model = AlipayCommerceEcTransAccountWithdrawModel()
  504. model.enterprise_id = enterprise.enterprise_id
  505. model.account_book_id = data.account_book_id
  506. model.amount = str(data.amount)
  507. model.out_biz_no = get_snowflake_id_str(auth.tenant_id)
  508. if data.remark:
  509. model.remark = data.remark
  510. request = AlipayCommerceEcTransAccountWithdrawRequest()
  511. request.biz_model = model
  512. client = AlipayClient.get_client()
  513. response = client.execute(request)
  514. if not response:
  515. raise CustomException(msg="提现失败: 无响应")
  516. result = AlipayCommerceEcTransAccountWithdrawResponse()
  517. result.parse_response_content(response)
  518. if not result.is_success():
  519. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  520. raise CustomException(msg=f"提现失败: {result.msg}")
  521. withdraw_crud = WithdrawCRUD(auth)
  522. withdraw_data = {
  523. "enterprise_id": data.enterprise_id,
  524. "out_biz_no": model.out_biz_no,
  525. "account_book_id": data.account_book_id,
  526. "amount": data.amount,
  527. # 专户提现到余额户是同步操作,要么执行成功,要么执行异常,
  528. # 出参status设计多余,遵循规范使用业务码区分成功与失败
  529. "status": WithdrawStatusEnum.SUCCESS.value,
  530. "order_no": result.order_no,
  531. }
  532. await withdraw_crud.create(withdraw_data)
  533. log.info(f"资金专户提现发起成功: 企业: {data.enterprise_id}, 金额: {data.amount}")
  534. return AccountOperationOutSchema(
  535. enterprise_id=data.enterprise_id,
  536. account_book_id=data.account_book_id,
  537. )
  538. @classmethod
  539. async def query_account_service(
  540. cls,
  541. auth: AuthSchema,
  542. data: AccountQuerySchema
  543. ) -> list[Any]:
  544. """
  545. 查询资金专户(调用支付宝接口)
  546. 调用: alipay.commerce.ec.trans.account.query
  547. """
  548. from alipay.aop.api.request.AlipayCommerceEcTransAccountQueryRequest import (
  549. AlipayCommerceEcTransAccountQueryRequest,
  550. )
  551. from alipay.aop.api.domain.AlipayCommerceEcTransAccountQueryModel import (
  552. AlipayCommerceEcTransAccountQueryModel,
  553. )
  554. from alipay.aop.api.response.AlipayCommerceEcTransAccountQueryResponse import (
  555. AlipayCommerceEcTransAccountQueryResponse,
  556. )
  557. from alipay.aop.api.domain.FundAccountApiDTO import (
  558. FundAccountApiDTO,
  559. )
  560. model = AlipayCommerceEcTransAccountQueryModel()
  561. model.enterprise_id = data.enterprise_id
  562. request = AlipayCommerceEcTransAccountQueryRequest()
  563. request.biz_model = model
  564. client = AlipayClient.get_client()
  565. response = client.execute(request)
  566. if not response:
  567. raise CustomException(msg="查询资金专户失败: 无响应")
  568. result = AlipayCommerceEcTransAccountQueryResponse()
  569. result.parse_response_content(response)
  570. if not result.is_success():
  571. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  572. raise CustomException(msg=f"查询资金专户失败: {result.msg}")
  573. collect = []
  574. for v in list(result.account_list or []):
  575. if not hasattr(v, "account_book_id"):
  576. continue
  577. if not hasattr(v, "scene") or v.scene != "B2B_TRANS":
  578. continue
  579. account = FundAccountApiDTO.to_alipay_dict(v)
  580. collect.append(account)
  581. return collect
  582. @classmethod
  583. async def transfer_detail_service(
  584. cls,
  585. auth: AuthSchema,
  586. out_biz_no: str
  587. ) -> TransferOutSchema:
  588. """
  589. 查询转账记录详情
  590. """
  591. crud = TransferCRUD(auth)
  592. transfer = await crud.get_by_out_biz_no(out_biz_no)
  593. if not transfer:
  594. raise CustomException(msg="转账记录不存在")
  595. transfer_result = TransferOutSchema.model_validate(transfer)
  596. # 查询三方订单号
  597. open_transfer_crud = OpenTransferCRUD(auth)
  598. open_transfer_data = await open_transfer_crud.get(out_biz_no=transfer.out_biz_no)
  599. if open_transfer_data:
  600. transfer_result.third_biz_no = open_transfer_data.third_biz_no
  601. return transfer_result
  602. @classmethod
  603. async def transfer_list_service(
  604. cls,
  605. auth: AuthSchema,
  606. page_no: int = 1,
  607. page_size: int = 20,
  608. search: dict | None = None,
  609. ) -> dict:
  610. """
  611. 查询转账记录列表
  612. """
  613. log.info(f"查询转账记录列表: {page_no}, {page_size}, {search}")
  614. crud = TransferCRUD(auth)
  615. offset = (page_no - 1) * page_size
  616. return await crud.page(
  617. offset=offset,
  618. limit=page_size,
  619. order_by=[{"id": "desc"}],
  620. search=search or {},
  621. out_schema=TransferListOutSchema,
  622. )
  623. @classmethod
  624. async def transfer_export_service(
  625. cls,
  626. auth: AuthSchema,
  627. start_time: str,
  628. end_time: str,
  629. enterprise_id: Optional[str] = None,
  630. ) -> bytes:
  631. """
  632. 导出转账记录报表为Excel文件
  633. """
  634. log.info(f"导出转账记录报表: {start_time} -> {end_time}")
  635. crud = TransferCRUD(auth)
  636. search = {
  637. "created_time__gte": start_time,
  638. "created_time__lte": end_time,
  639. }
  640. if enterprise_id:
  641. search["enterprise_id"] = enterprise_id
  642. records = await crud.list(
  643. search=search,
  644. order_by=[{"id": "desc"}],
  645. )
  646. from app.utils.excel_util import ExcelUtil
  647. status_map = {
  648. "DEALING": "处理中",
  649. "SUCCESS": "成功",
  650. "FAIL": "失败",
  651. "REFUND": "退票",
  652. }
  653. payee_type_map = {
  654. "ALIPAY_ACCOUNT": "支付宝账户",
  655. "BANK_CARD": "银行卡",
  656. }
  657. list_data = []
  658. for i, record in enumerate(records, start=1):
  659. payee_info = record.payee_info or {}
  660. list_data.append({
  661. "序号": i,
  662. "订单号": record.out_biz_no or "",
  663. "商户订单号": record.order_no or "",
  664. "金额(元)": str(record.amount or 0),
  665. "收款方姓名": payee_info.get("name", ""),
  666. "收款方类型": payee_type_map.get(payee_info.get("identity_type", ""), ""),
  667. "状态": status_map.get(record.status, record.status),
  668. "转账标题": record.order_title or "",
  669. "创建时间": record.created_time.strftime("%Y-%m-%d %H:%M:%S") if record.created_time else "",
  670. })
  671. mapping_dict = {
  672. "序号": "序号",
  673. "订单号": "订单号",
  674. "商户订单号": "商户订单号",
  675. "金额(元)": "金额(元)",
  676. "收款方姓名": "收款方姓名",
  677. "收款方类型": "收款方类型",
  678. "状态": "状态",
  679. "转账标题": "转账标题",
  680. "创建时间": "创建时间",
  681. }
  682. return ExcelUtil.export_list2excel(list_data, mapping_dict)
  683. @classmethod
  684. async def apply_receipt_service(
  685. cls,
  686. auth: AuthSchema,
  687. redis: Redis,
  688. data: ReceiptApplySchema,
  689. ) -> str:
  690. """
  691. 申请转账业务回单
  692. 调用: alipay.commerce.ec.trans.receipt.apply
  693. 参数:
  694. - enterprise_id: 企业ID
  695. - order_no: 支付宝转账单号
  696. 返回: file_id
  697. """
  698. from app.core.redis_crud import RedisCURD
  699. redis_crud = RedisCURD(redis)
  700. cache_key = f"receipt:{data.enterprise_id}:{data.order_no}"
  701. cached_file_id = await redis_crud.get(cache_key)
  702. if cached_file_id:
  703. log.info(f"使用缓存的 file_id: {cached_file_id}")
  704. return cached_file_id
  705. crud = EnterpriseCRUD(auth)
  706. enterprise = await crud.get_by_enterprise_id(data.enterprise_id)
  707. if not enterprise:
  708. raise CustomException(msg="企业不存在")
  709. from alipay.aop.api.request.AlipayCommerceEcTransReceiptApplyRequest import (
  710. AlipayCommerceEcTransReceiptApplyRequest,
  711. )
  712. from alipay.aop.api.domain.AlipayCommerceEcTransReceiptApplyModel import (
  713. AlipayCommerceEcTransReceiptApplyModel,
  714. )
  715. from alipay.aop.api.response.AlipayCommerceEcTransReceiptApplyResponse import (
  716. AlipayCommerceEcTransReceiptApplyResponse,
  717. )
  718. model = AlipayCommerceEcTransReceiptApplyModel()
  719. model.enterprise_id = data.enterprise_id
  720. model.order_no = data.order_no
  721. request = AlipayCommerceEcTransReceiptApplyRequest()
  722. request.biz_model = model
  723. client = AlipayClient.get_client()
  724. response = client.execute(request)
  725. if not response:
  726. raise CustomException(msg="申请回单失败: 无响应")
  727. result = AlipayCommerceEcTransReceiptApplyResponse()
  728. result.parse_response_content(response)
  729. if not result.is_success():
  730. # 清除缓存
  731. await redis_crud.delete(cache_key)
  732. raise CustomException(msg=f"申请回单失败: {result.msg}")
  733. file_id = str(result.file_id)
  734. await redis_crud.set(cache_key, file_id, expire=172800)
  735. log.info(f"申请回单成功: order_no={data.order_no}, file_id={file_id}")
  736. return file_id
  737. @classmethod
  738. async def query_receipt_service(cls, enterprise_id: str, file_id: str) -> dict:
  739. """
  740. 查询回单状态
  741. 调用: alipay.commerce.ec.trans.receipt.query
  742. 参数:
  743. - file_id: 文件申请号
  744. 返回: {file_id, status, download_url, error_message}
  745. """
  746. from alipay.aop.api.request.AlipayCommerceEcTransReceiptQueryRequest import (
  747. AlipayCommerceEcTransReceiptQueryRequest,
  748. )
  749. from alipay.aop.api.response.AlipayCommerceEcTransReceiptQueryResponse import (
  750. AlipayCommerceEcTransReceiptQueryResponse,
  751. )
  752. from alipay.aop.api.domain.AlipayCommerceEcTransReceiptQueryModel import (
  753. AlipayCommerceEcTransReceiptQueryModel,
  754. )
  755. model = AlipayCommerceEcTransReceiptQueryModel()
  756. model.enterprise_id = enterprise_id
  757. model.file_id = file_id
  758. request = AlipayCommerceEcTransReceiptQueryRequest()
  759. request.biz_model = model
  760. client = AlipayClient.get_client()
  761. response = client.execute(request)
  762. if not response:
  763. raise CustomException(msg="查询回单失败: 无响应")
  764. result = AlipayCommerceEcTransReceiptQueryResponse()
  765. result.parse_response_content(response)
  766. if not result.is_success():
  767. raise CustomException(msg=f"查询回单失败: {result.msg}")
  768. data = {
  769. "file_id": file_id,
  770. "status": result.status,
  771. "download_url": result.download_url,
  772. "error_message": result.error_message,
  773. }
  774. return data
  775. @classmethod
  776. async def transfer_sync_status_service(
  777. cls,
  778. auth: AuthSchema,
  779. data: "TransferSyncStatusSchema",
  780. ) -> dict:
  781. """
  782. 手动同步转账状态(管理员补录)
  783. 用于修复因通知丢失而卡在 DEALING 的转账记录
  784. """
  785. from app.plugin.module_payment.account.crud import TransferCRUD
  786. from app.plugin.module_payment.account.schema import TransferSyncStatusSchema
  787. crud = TransferCRUD(auth)
  788. transfer = await crud.get_by_out_biz_no(data.out_biz_no)
  789. if not transfer:
  790. raise CustomException(msg=f"转账记录不存在: {data.out_biz_no}")
  791. if transfer.status != "DEALING" and data.status == "SUCCESS":
  792. raise CustomException(msg=f"转账记录当前状态为 {transfer.status},无需同步")
  793. update_data = {"status": data.status}
  794. if data.error_code:
  795. update_data["error_code"] = data.error_code
  796. if data.error_msg:
  797. update_data["error_msg"] = data.error_msg
  798. for key, value in update_data.items():
  799. if hasattr(transfer, key):
  800. setattr(transfer, key, value)
  801. await auth.db.flush()
  802. await auth.db.refresh(transfer)
  803. log.info(f"手动同步转账状态成功: out_biz_no={data.out_biz_no}, {transfer.status}")
  804. return {
  805. "out_biz_no": transfer.out_biz_no,
  806. "status": transfer.status,
  807. "error_code": transfer.error_code,
  808. "error_msg": transfer.error_msg,
  809. }
  810. @classmethod
  811. async def transfer_sync_all_service(
  812. cls,
  813. auth: AuthSchema,
  814. ) -> dict:
  815. """
  816. 全量同步转账状态
  817. 尝试调 fund.trans.common.query,如无权限则降级为列出 DEALING 记录供手动同步
  818. """
  819. from sqlalchemy import select
  820. from app.plugin.module_payment.account.model import TransferModel
  821. from app.plugin.module_payment.account.enums import TransferStatusEnum
  822. stmt = select(TransferModel).where(
  823. TransferModel.out_biz_no.isnot(None),
  824. ).order_by(TransferModel.id.asc())
  825. result = await auth.db.execute(stmt)
  826. all_transfers = result.scalars().all()
  827. synced = 0
  828. errors = 0
  829. details = []
  830. _has_permission = True
  831. for transfer in all_transfers:
  832. out_biz_no = transfer.out_biz_no
  833. eid = transfer.enterprise_id
  834. if not out_biz_no or not eid:
  835. continue
  836. try:
  837. result = await cls._sync_transfer_detail(auth, out_biz_no, eid)
  838. if result is False:
  839. # 两个方案都失败了(无权限),停止全量同步
  840. _has_permission = False
  841. break
  842. if isinstance(result, str):
  843. synced += 1
  844. details.append({"out_biz_no": out_biz_no, "old_status": transfer.status, "new_status": result})
  845. else:
  846. details.append({"out_biz_no": out_biz_no, "status": transfer.status, "action": "no_change"})
  847. except Exception as e:
  848. errors += 1
  849. details.append({"out_biz_no": out_biz_no, "status": transfer.status, "error": str(e)})
  850. log.warning(f"全量同步 - 查询失败: out_biz_no={out_biz_no}, err={e}")
  851. if not _has_permission:
  852. dealing = [t for t in all_transfers if t.status == TransferStatusEnum.DEALING.value]
  853. return {
  854. "total": len(all_transfers),
  855. "synced": synced,
  856. "no_permission": True,
  857. "dealing_count": len(dealing),
  858. "details": [{"out_biz_no": t.out_biz_no, "status": t.status} for t in dealing],
  859. "note": "无法通过支付宝 API 查询转账状态,请在开放平台开通 alipay.fund.trans.common.query 权限,或逐个使用 sync-status 手动补录",
  860. }
  861. if synced > 0:
  862. await auth.db.flush()
  863. return {
  864. "total": len(all_transfers),
  865. "synced": synced,
  866. "errors": errors,
  867. "details": details,
  868. }
  869. @classmethod
  870. async def _sync_transfer_detail(
  871. cls,
  872. auth: AuthSchema,
  873. out_biz_no: str,
  874. enterprise_id: str,
  875. ) -> str | bool | None:
  876. """查询单笔转账详情并更新本地记录
  877. 优先调 fund.trans.common.query,无权限时改用 consume.detail.query(用 order_no 当 pay_no 查)
  878. 返回: 新状态str / False(无权限) / None(失败/无变化)
  879. """
  880. from sqlalchemy import select, update as sa_update
  881. from app.plugin.module_payment.account.model import TransferModel
  882. from app.core.alipay import AlipayClient
  883. # 先查本地记录
  884. tf_stmt = select(TransferModel).where(TransferModel.out_biz_no == out_biz_no)
  885. tf_result = await auth.db.execute(tf_stmt)
  886. local_transfer = tf_result.scalar_one_or_none()
  887. # — 方案A: fund.trans.common.query —
  888. try:
  889. from alipay.aop.api.request.AlipayFundTransCommonQueryRequest import (
  890. AlipayFundTransCommonQueryRequest,
  891. )
  892. from alipay.aop.api.domain.AlipayFundTransCommonQueryModel import (
  893. AlipayFundTransCommonQueryModel,
  894. )
  895. from alipay.aop.api.response.AlipayFundTransCommonQueryResponse import (
  896. AlipayFundTransCommonQueryResponse,
  897. )
  898. model = AlipayFundTransCommonQueryModel()
  899. model.out_biz_no = out_biz_no
  900. model.product_code = "TRANS_ACCOUNT_NO_PWD"
  901. model.biz_scene = "DIRECT_TRANSFER"
  902. request = AlipayFundTransCommonQueryRequest()
  903. request.biz_model = model
  904. client = AlipayClient.get_client()
  905. response = client.execute(request)
  906. if response:
  907. result = AlipayFundTransCommonQueryResponse()
  908. result.parse_response_content(response)
  909. if result.is_success():
  910. alipay_status = getattr(result, 'status', None)
  911. if alipay_status and alipay_status != "DEALING":
  912. return await cls._apply_transfer_update(auth, out_biz_no, result, alipay_status)
  913. return None
  914. sub_msg = getattr(result, 'sub_msg', '') or ''
  915. if '权限' not in sub_msg and 'NO_PERMISSION' not in sub_msg:
  916. return None
  917. # 权限不足,继续方案B
  918. except ImportError:
  919. pass
  920. # — 方案B: consume.detail.query(用 order_no 当 pay_no 查) —
  921. order_no = local_transfer.order_no if local_transfer else None
  922. if not order_no:
  923. log.warning(f"无 order_no 可用于查询: out_biz_no={out_biz_no}")
  924. return False
  925. try:
  926. from alipay.aop.api.request.AlipayCommerceEcConsumeDetailQueryRequest import (
  927. AlipayCommerceEcConsumeDetailQueryRequest,
  928. )
  929. from alipay.aop.api.domain.AlipayCommerceEcConsumeDetailQueryModel import (
  930. AlipayCommerceEcConsumeDetailQueryModel,
  931. )
  932. from alipay.aop.api.response.AlipayCommerceEcConsumeDetailQueryResponse import (
  933. AlipayCommerceEcConsumeDetailQueryResponse,
  934. )
  935. model = AlipayCommerceEcConsumeDetailQueryModel()
  936. model.pay_no = order_no
  937. model.enterprise_id = enterprise_id
  938. request = AlipayCommerceEcConsumeDetailQueryRequest()
  939. request.biz_model = model
  940. client = AlipayClient.get_client()
  941. response = client.execute(request)
  942. if not response:
  943. return False
  944. result = AlipayCommerceEcConsumeDetailQueryResponse()
  945. result.parse_response_content(response)
  946. if not result.is_success():
  947. sub_code = getattr(result, 'sub_code', '') or ''
  948. sub_msg = getattr(result, 'sub_msg', '') or ''
  949. # 权限不足
  950. if '权限' in sub_msg or 'NO_PERMISSION' in sub_code:
  951. return False
  952. log.warning(f"consume.detail.query 查无记录: out_biz_no={out_biz_no}, err={sub_msg}")
  953. return None
  954. consume_info = getattr(result, 'consume_info', None)
  955. if not consume_info:
  956. return None
  957. consume_type = getattr(consume_info, 'consume_type', '')
  958. if consume_type != "TRANSFER":
  959. return None
  960. notify_reason = getattr(consume_info, 'notify_reason', '') or ''
  961. if 'SUCCESS' in notify_reason.upper():
  962. new_status = "SUCCESS"
  963. elif 'FAIL' in notify_reason.upper():
  964. new_status = "FAIL"
  965. else:
  966. return None
  967. update_data = {"status": new_status}
  968. pay_no = getattr(consume_info, 'pay_no', None)
  969. if pay_no and pay_no != order_no:
  970. update_data["order_no"] = pay_no
  971. upd = sa_update(TransferModel).where(
  972. TransferModel.out_biz_no == out_biz_no
  973. ).values(**update_data)
  974. await auth.db.execute(upd)
  975. log.info(f"转账同步(consume详情) - out_biz_no={out_biz_no}, status={new_status}")
  976. return new_status
  977. except ImportError:
  978. log.warning("consume.detail.query SDK 不可用")
  979. return False
  980. except Exception as e:
  981. log.warning(f"consume.detail.query 异常: out_biz_no={out_biz_no}, err={e}")
  982. return False
  983. @classmethod
  984. async def _apply_transfer_update(
  985. cls,
  986. auth: AuthSchema,
  987. out_biz_no: str,
  988. result: object,
  989. alipay_status: str,
  990. ) -> str | None:
  991. """根据 fund.trans.common.query 结果更新本地记录"""
  992. from sqlalchemy import update as sa_update
  993. from app.plugin.module_payment.account.model import TransferModel
  994. update_data = {"status": alipay_status}
  995. order_no = getattr(result, 'order_id', None)
  996. pay_fund_order_id = getattr(result, 'pay_fund_order_id', None)
  997. trans_amount = getattr(result, 'trans_amount', None)
  998. error_code = getattr(result, 'error_code', None)
  999. fail_reason = getattr(result, 'fail_reason', None)
  1000. if order_no:
  1001. update_data["order_no"] = order_no
  1002. if pay_fund_order_id:
  1003. update_data["fund_order_id"] = pay_fund_order_id
  1004. if trans_amount:
  1005. update_data["amount"] = Decimal(str(trans_amount))
  1006. if error_code:
  1007. update_data["error_code"] = error_code
  1008. if fail_reason:
  1009. update_data["error_msg"] = fail_reason
  1010. if update_data.get("status") != "DEALING":
  1011. upd = sa_update(TransferModel).where(
  1012. TransferModel.out_biz_no == out_biz_no
  1013. ).values(**update_data)
  1014. await auth.db.execute(upd)
  1015. log.info(f"转账详情同步 - out_biz_no={out_biz_no}, status={alipay_status}")
  1016. return alipay_status
  1017. return None
  1018. @classmethod
  1019. async def update_transfer_status_service(
  1020. cls,
  1021. auth: AuthSchema,
  1022. order_no: str,
  1023. status: str,
  1024. ext_info: dict = {}
  1025. ) -> None:
  1026. """
  1027. 更新转账状态(由通知处理器调用)
  1028. """
  1029. crud = TransferCRUD(auth)
  1030. transfer = await crud.get_by_order_no(order_no)
  1031. if not transfer:
  1032. log.warning(f"转账记录不存在: {order_no}")
  1033. return
  1034. update_data = {}
  1035. update_data["status"] = status
  1036. if ext_info:
  1037. update_data["ext_info"] = ext_info
  1038. await crud.update_by_order_no(order_no, update_data)
  1039. @classmethod
  1040. async def update_deposit_status_service(
  1041. cls,
  1042. auth: AuthSchema,
  1043. out_biz_no: str,
  1044. status: str,
  1045. ) -> None:
  1046. """
  1047. 更新充值状态(由通知处理器调用)
  1048. """
  1049. crud = DepositCRUD(auth)
  1050. deposit = await crud.get_by_out_biz_no(out_biz_no)
  1051. if not deposit:
  1052. log.warning(f"充值记录不存在: {out_biz_no}")
  1053. return
  1054. update_data = {"status": status}
  1055. await crud.update_by_out_biz_no(out_biz_no, update_data)
  1056. @classmethod
  1057. async def update_withdraw_status_service(
  1058. cls,
  1059. auth: AuthSchema,
  1060. out_biz_no: str,
  1061. status: str,
  1062. error_code: str | None = None,
  1063. error_msg: str | None = None,
  1064. ) -> None:
  1065. """
  1066. 更新提现状态(由通知处理器调用)
  1067. """
  1068. crud = WithdrawCRUD(auth)
  1069. withdraw = await crud.get_by_out_biz_no(out_biz_no)
  1070. if not withdraw:
  1071. log.warning(f"提现记录不存在: {out_biz_no}")
  1072. return
  1073. update_data = {"status": status}
  1074. if error_code:
  1075. update_data["error_code"] = error_code
  1076. if error_msg:
  1077. update_data["error_msg"] = error_msg
  1078. await crud.update_by_out_biz_no(out_biz_no, update_data)
  1079. @classmethod
  1080. async def consume_detail_query_service(
  1081. cls,
  1082. auth: AuthSchema,
  1083. pay_no: str,
  1084. enterprise_id: str | None = None,
  1085. ant_shop_id: str | None = None,
  1086. query_options: list[str] | None = None,
  1087. ) -> dict:
  1088. """
  1089. 账单详情查询(✅)
  1090. 调用: alipay.commerce.ec.consume.detail.query
  1091. 用于查询企业码账单详情,支持查询关联退款、订单、票据等信息。
  1092. """
  1093. from alipay.aop.api.request.AlipayCommerceEcConsumeDetailQueryRequest import (
  1094. AlipayCommerceEcConsumeDetailQueryRequest,
  1095. )
  1096. from alipay.aop.api.domain.AlipayCommerceEcConsumeDetailQueryModel import (
  1097. AlipayCommerceEcConsumeDetailQueryModel,
  1098. )
  1099. from alipay.aop.api.response.AlipayCommerceEcConsumeDetailQueryResponse import (
  1100. AlipayCommerceEcConsumeDetailQueryResponse,
  1101. )
  1102. model = AlipayCommerceEcConsumeDetailQueryModel()
  1103. model.pay_no = pay_no
  1104. if enterprise_id:
  1105. model.enterprise_id = enterprise_id
  1106. if ant_shop_id:
  1107. model.ant_shop_id = ant_shop_id
  1108. if query_options:
  1109. model.query_options = query_options
  1110. request = AlipayCommerceEcConsumeDetailQueryRequest()
  1111. request.biz_model = model
  1112. client = AlipayClient.get_client()
  1113. response = client.execute(request)
  1114. if not response:
  1115. raise CustomException(msg="账单详情查询失败: 无响应")
  1116. result = AlipayCommerceEcConsumeDetailQueryResponse()
  1117. result.parse_response_content(response)
  1118. if not result.is_success():
  1119. log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
  1120. raise CustomException(msg=f"账单详情查询失败: {result.msg}")
  1121. consume_info = result.consume_info
  1122. if not consume_info:
  1123. raise CustomException(msg="账单详情查询失败: 无账单信息")
  1124. return {
  1125. "account_id": consume_info.account_id,
  1126. "pay_no": consume_info.pay_no,
  1127. "consume_type": consume_info.consume_type,
  1128. "gmt_biz_create": consume_info.gmt_biz_create,
  1129. "consume_biz_type": consume_info.consume_biz_type,
  1130. "consume_amount": consume_info.consume_amount,
  1131. "order_complete_label": consume_info.order_complete_label,
  1132. "refund_status": consume_info.refund_status,
  1133. "refund_amount": consume_info.refund_amount,
  1134. "peer_payer_card_name": consume_info.peer_payer_card_name,
  1135. "user_id": getattr(consume_info, 'user_id', None),
  1136. "open_id": getattr(consume_info, 'open_id', None),
  1137. "enterprise_id": consume_info.enterprise_id,
  1138. "employee_id": consume_info.employee_id,
  1139. "enterprise_name": getattr(consume_info, 'enterprise_name', None),
  1140. "employee_name": getattr(consume_info, 'employee_name', None),
  1141. "consume_scene_code": getattr(consume_info, 'consume_scene_code', None),
  1142. "consume_type_sub_category": getattr(consume_info, 'consume_type_sub_category', None),
  1143. "consume_title": getattr(consume_info, 'consume_title', None),
  1144. "gmt_pay": getattr(consume_info, 'gmt_pay', None),
  1145. "gmt_refund": getattr(consume_info, 'gmt_refund', None),
  1146. "pay_amount": getattr(consume_info, 'pay_amount', None),
  1147. "invoice_amount": getattr(consume_info, 'invoice_amount', None),
  1148. "peer_pay_amount": getattr(consume_info, 'peer_pay_amount', None),
  1149. "subsidy_amount": getattr(consume_info, 'subsidy_amount', None),
  1150. "ext_infos": getattr(consume_info, 'ext_infos', None),
  1151. }