payment.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. """支付相关 API"""
  2. from typing import Optional
  3. from datetime import datetime
  4. from decimal import Decimal
  5. from fastapi import APIRouter, Depends, HTTPException, Request, Response
  6. from sqlalchemy.orm import Session
  7. from pydantic import BaseModel, Field
  8. from app.core.database import get_db
  9. from app.core.auth import get_current_user
  10. from app.core.response import success_response, error_response
  11. from app.core.wechat_pay import wechat_pay, get_wechat_pay_v2
  12. from app.core.config import settings
  13. from app.core.utils import generate_id
  14. from app.crud.order import order_crud
  15. from app.models.models import BizWechatPay
  16. router = APIRouter(prefix="/api/payment", tags=["支付接口"])
  17. WECHAT_XML_CONTENT_TYPE = "application/xml; charset=utf-8"
  18. def _parse_wechat_pay_time(time_end: Optional[str]) -> Optional[datetime]:
  19. """解析微信 V2 回调支付时间(yyyyMMddHHmmss)。"""
  20. if not time_end:
  21. return None
  22. try:
  23. return datetime.strptime(time_end, "%Y%m%d%H%M%S")
  24. except Exception:
  25. return None
  26. def _to_int_or_none(value) -> Optional[int]:
  27. if value in (None, ""):
  28. return None
  29. try:
  30. return int(value)
  31. except Exception:
  32. return None
  33. def _upsert_wechat_pay_record(
  34. db: Session,
  35. *,
  36. order_id: str,
  37. out_trade_no: str,
  38. total_fee: int,
  39. trade_state: Optional[str] = None,
  40. trade_state_desc: Optional[str] = None,
  41. transaction_id: Optional[str] = None,
  42. cash_fee: Optional[int] = None,
  43. pay_time: Optional[datetime] = None,
  44. operator_id: Optional[str] = None,
  45. operator_name: Optional[str] = None,
  46. ) -> BizWechatPay:
  47. """按商户单号幂等写入/更新微信支付流水。"""
  48. record = (
  49. db.query(BizWechatPay)
  50. .filter(BizWechatPay.out_trade_no == out_trade_no, BizWechatPay.is_del == 0)
  51. .first()
  52. )
  53. if record:
  54. record.order_id = order_id
  55. record.total_fee = total_fee
  56. if trade_state:
  57. record.trade_state = trade_state
  58. if trade_state_desc:
  59. record.trade_state_desc = trade_state_desc
  60. if transaction_id:
  61. record.transaction_id = transaction_id
  62. if cash_fee is not None:
  63. record.cash_fee = cash_fee
  64. if pay_time:
  65. record.pay_time = pay_time
  66. record.update_at = datetime.now()
  67. record.update_by = operator_id
  68. return record
  69. record = BizWechatPay(
  70. id=generate_id(),
  71. order_id=order_id,
  72. out_trade_no=out_trade_no,
  73. transaction_id=transaction_id,
  74. total_fee=total_fee,
  75. cash_fee=cash_fee,
  76. pay_time=pay_time,
  77. trade_state=trade_state or "NOTPAY",
  78. trade_state_desc=trade_state_desc,
  79. create_by=operator_id,
  80. create_name=operator_name,
  81. update_by=operator_id,
  82. )
  83. db.add(record)
  84. return record
  85. def _wechat_xml_response(return_code: str, return_msg: str, status_code: int = 200) -> Response:
  86. xml = (
  87. "<xml>"
  88. f"<return_code><![CDATA[{return_code}]]></return_code>"
  89. f"<return_msg><![CDATA[{return_msg}]]></return_msg>"
  90. "</xml>"
  91. )
  92. return Response(content=xml, status_code=status_code, media_type=WECHAT_XML_CONTENT_TYPE)
  93. def _wechat_success() -> Response:
  94. return _wechat_xml_response("SUCCESS", "OK")
  95. def _wechat_fail(msg: str = "FAIL") -> Response:
  96. return _wechat_xml_response("FAIL", msg)
  97. class WechatPayRequest(BaseModel):
  98. """微信支付请求"""
  99. order_id: str = Field(..., description="订单ID")
  100. openid: Optional[str] = Field(None, description="用户OpenID")
  101. class WechatPayResponse(BaseModel):
  102. """微信支付响应"""
  103. success: bool
  104. prepay_id: Optional[str] = None
  105. pay_sign: Optional[str] = None
  106. timestamp: Optional[str] = None
  107. nonce_str: Optional[str] = None
  108. error: Optional[str] = None
  109. @router.post("/wechat/create", response_model=dict)
  110. async def create_wechat_pay(
  111. request: WechatPayRequest,
  112. db: Session = Depends(get_db),
  113. current_user: dict = Depends(get_current_user)
  114. ):
  115. """
  116. 创建微信支付订单
  117. 返回调起微信支付所需的参数
  118. """
  119. try:
  120. # 获取订单信息
  121. order = order_crud.get(db, request.order_id)
  122. if not order:
  123. return error_response(404, "订单不存在")
  124. # 检查订单状态
  125. if getattr(order, "payment_method", None) != "online":
  126. return error_response(400, "线下支付订单不支持微信支付")
  127. pay_status = getattr(order, "pay_status", None)
  128. if pay_status:
  129. if pay_status != "pending_payment":
  130. return error_response(400, "订单状态不允许支付")
  131. elif order.status != "pending_payment":
  132. return error_response(400, "订单状态不允许支付")
  133. # 检查金额
  134. total_amount = int(float(order.total_amount) * 100) # 元转分(微信支付单位为分)
  135. if total_amount <= 0:
  136. return error_response(400, "订单金额必须大于0")
  137. # 生成商户订单号
  138. out_trade_no = order.order_no
  139. # 回调地址
  140. notify_url = settings.WXPAY_NOTIFY_URL
  141. # 调用微信支付统一下单
  142. pay_client = get_wechat_pay_v2()
  143. result = pay_client.unified_order(
  144. out_trade_no=out_trade_no,
  145. total_fee=total_amount,
  146. body="陇耘鲜达-订单支付",
  147. openid=request.openid,
  148. trade_type="JSAPI"
  149. )
  150. if result.get("success"):
  151. pp = result.get("pay_params") or {}
  152. _upsert_wechat_pay_record(
  153. db,
  154. order_id=str(order.id),
  155. out_trade_no=out_trade_no,
  156. total_fee=total_amount,
  157. trade_state="NOTPAY",
  158. trade_state_desc="统一下单成功,待支付",
  159. operator_id=current_user.get("user_id"),
  160. operator_name=current_user.get("real_name"),
  161. )
  162. db.commit()
  163. return success_response({
  164. "prepay_id": result.get("prepay_id"),
  165. "order_id": order.id,
  166. "order_no": order.order_no,
  167. "appId": pp.get("appId"),
  168. "timeStamp": pp.get("timeStamp"),
  169. "nonceStr": pp.get("nonceStr"),
  170. "package": pp.get("package"),
  171. "signType": pp.get("signType"),
  172. "paySign": pp.get("paySign"),
  173. })
  174. else:
  175. return error_response(500, result.get("error", "创建支付订单失败"))
  176. except Exception as e:
  177. return error_response(500, f"创建支付订单失败: {str(e)}")
  178. @router.get("/query/{order_no}", response_model=dict)
  179. def query_payment_status(
  180. order_no: str,
  181. db: Session = Depends(get_db),
  182. current_user: dict = Depends(get_current_user)
  183. ):
  184. """
  185. 查询支付状态
  186. 用于前端轮询查询支付结果
  187. """
  188. try:
  189. order = order_crud.get_by_order_no(db, order_no)
  190. if not order:
  191. return error_response(404, "订单不存在")
  192. return success_response({
  193. "order_id": order.id,
  194. "order_no": order.order_no,
  195. "status": order.status,
  196. "is_paid": (getattr(order, "pay_status", None) in ["paid", "refunding", "refunded"]) if getattr(order, "pay_status", None) else (order.status not in ["pending_payment", "cancelled"])
  197. })
  198. except Exception as e:
  199. return error_response(500, f"查询失败: {str(e)}")
  200. @router.post("/notify")
  201. async def wechat_pay_notify(request: Request):
  202. """
  203. 微信支付回调通知
  204. 注意:此接口不需要认证
  205. """
  206. try:
  207. # 接收微信回调数据
  208. body = await request.body()
  209. xml_data = body.decode('utf-8')
  210. # 解析回调数据
  211. pay_client = get_wechat_pay_v2()
  212. result = pay_client.parse_notify(xml_data)
  213. if not result.get("success"):
  214. return _wechat_fail("签名验证失败")
  215. data = result.get("data", {})
  216. out_trade_no = (data.get("out_trade_no") or "").strip()
  217. total_fee_raw = data.get("total_fee") # 订单金额(分)
  218. transaction_id = (data.get("transaction_id") or "").strip() or None
  219. cash_fee = _to_int_or_none(data.get("cash_fee"))
  220. pay_time = _parse_wechat_pay_time(data.get("time_end"))
  221. trade_state = (data.get("trade_state") or "SUCCESS").strip() or "SUCCESS"
  222. trade_state_desc = (data.get("trade_state_desc") or data.get("return_msg") or "支付成功").strip()
  223. if not out_trade_no:
  224. return _wechat_fail("缺少商户订单号")
  225. try:
  226. total_fee = int(total_fee_raw)
  227. except (TypeError, ValueError):
  228. return _wechat_fail("金额格式错误")
  229. # 查询订单并更新状态
  230. db = next(get_db())
  231. try:
  232. order = order_crud.get_by_order_no(db, out_trade_no)
  233. if not order:
  234. return _wechat_fail("订单不存在")
  235. expected_fee = int(Decimal(str(order.total_amount or 0)) * 100)
  236. if total_fee != expected_fee:
  237. return _wechat_fail("金额校验失败")
  238. # 幂等:已支付订单重复回调,直接返回成功
  239. if getattr(order, "pay_status", None) in ["paid", "refunding", "refunded"]:
  240. _upsert_wechat_pay_record(
  241. db,
  242. order_id=str(order.id),
  243. out_trade_no=out_trade_no,
  244. total_fee=total_fee,
  245. trade_state=trade_state,
  246. trade_state_desc=trade_state_desc,
  247. transaction_id=transaction_id,
  248. cash_fee=cash_fee,
  249. pay_time=pay_time,
  250. )
  251. db.commit()
  252. return _wechat_success()
  253. order.paid_amount = Decimal(total_fee) / Decimal("100")
  254. if getattr(order, "order_status", None) != "cancelled":
  255. order.order_status = "pending_confirm"
  256. if getattr(order, "delivery_status", None) in [None, ""]:
  257. order.delivery_status = "pending_dispatch"
  258. order_crud.sync_pay_fields_from_amounts(order)
  259. order.update_at = datetime.now()
  260. _upsert_wechat_pay_record(
  261. db,
  262. order_id=str(order.id),
  263. out_trade_no=out_trade_no,
  264. total_fee=total_fee,
  265. trade_state=trade_state,
  266. trade_state_desc=trade_state_desc,
  267. transaction_id=transaction_id,
  268. cash_fee=cash_fee,
  269. pay_time=pay_time,
  270. )
  271. db.commit()
  272. return _wechat_success()
  273. finally:
  274. db.close()
  275. except Exception as e:
  276. return _wechat_fail(f"处理回调失败: {str(e)}")
  277. @router.post("/refund-notify")
  278. async def wechat_refund_notify(request: Request):
  279. """
  280. 微信退款回调通知
  281. 注意:此接口不需要认证
  282. """
  283. try:
  284. body = await request.body()
  285. xml_data = body.decode('utf-8')
  286. pay_client = get_wechat_pay_v2()
  287. result = pay_client.parse_notify(xml_data)
  288. if not result.get("success"):
  289. return _wechat_fail("签名验证失败")
  290. data = result.get("data", {})
  291. out_trade_no = (data.get("out_trade_no") or "").strip()
  292. refund_id = data.get("refund_id") # 退款单号
  293. if not out_trade_no:
  294. return _wechat_fail("缺少商户订单号")
  295. # 查询订单并更新退款状态
  296. db = next(get_db())
  297. try:
  298. order = order_crud.get_by_order_no(db, out_trade_no)
  299. if not order:
  300. return _wechat_fail("订单不存在")
  301. # 幂等:已经是退款态则直接成功返回
  302. if getattr(order, "pay_status", None) == "refunded":
  303. return _wechat_success()
  304. order_crud.update_refund_status(db, order.id, "refunded", refund_id)
  305. return _wechat_success()
  306. finally:
  307. db.close()
  308. except Exception as e:
  309. return _wechat_fail(f"处理退款回调失败: {str(e)}")
  310. @router.post("/close/{order_no}", response_model=dict)
  311. def close_payment(
  312. order_no: str,
  313. db: Session = Depends(get_db),
  314. current_user: dict = Depends(get_current_user)
  315. ):
  316. """
  317. 关闭支付订单
  318. 用于订单超时未支付时关闭订单
  319. """
  320. try:
  321. order = order_crud.get_by_order_no(db, order_no)
  322. if not order:
  323. return error_response(404, "订单不存在")
  324. pay_status = getattr(order, "pay_status", None)
  325. if pay_status:
  326. if pay_status != "pending_payment":
  327. return error_response(400, "订单状态不允许关闭")
  328. elif order.status != "pending_payment":
  329. return error_response(400, "订单状态不允许关闭")
  330. # 调用微信支付关闭订单
  331. pay_client = get_wechat_pay_v2()
  332. out_trade_no = order.order_no
  333. result = pay_client.close_order(out_trade_no)
  334. if result.get("success"):
  335. return success_response({"message": "订单已关闭"})
  336. else:
  337. # 即使微信关闭失败,也更新本地订单状态
  338. order_crud.update_status(db, order.id, "cancelled", {
  339. "cancel_reason": "支付超时自动关闭"
  340. })
  341. return success_response({"message": "订单已关闭(本地)"})
  342. except Exception as e:
  343. return error_response(500, f"关闭订单失败: {str(e)}")
  344. @router.get("/config", response_model=dict)
  345. def get_pay_config(
  346. current_user: dict = Depends(get_current_user)
  347. ):
  348. """
  349. 获取微信支付配置信息
  350. 返回支付所需的AppID等配置(不含敏感信息)
  351. """
  352. return success_response({
  353. "appId": settings.WXPAY_MINI_APPID,
  354. "enabled": bool(settings.WXPAY_MCHID and settings.WXPAY_MINI_APPID)
  355. })