add wxpay
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ backend/static
|
||||
backend/log/
|
||||
backend/.env
|
||||
backend/alembic/versions/
|
||||
backend/app/admin/cert/
|
||||
.venv/
|
||||
.vscode/
|
||||
.cloudbase/
|
||||
|
||||
@@ -8,13 +8,15 @@ from backend.app.admin.api.v1.dict import router as dict_router
|
||||
from backend.app.admin.api.v1.audit_log import router as audit_log_router
|
||||
from backend.app.admin.api.v1.coupon import router as coupon_router
|
||||
from backend.app.admin.api.v1.points import router as points_router
|
||||
from backend.app.admin.api.v1.wxpay import router as wxpay_router
|
||||
from backend.core.conf import settings
|
||||
|
||||
v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH)
|
||||
|
||||
v1.include_router(wx_router, prefix='/wx', tags=['微信服务'])
|
||||
v1.include_router(wxpay_router, prefix='/wxpay', tags=['微信支付'])
|
||||
v1.include_router(file_router, prefix='/file', tags=['文件服务'])
|
||||
v1.include_router(dict_router, prefix='/dict', tags=['字典服务'])
|
||||
v1.include_router(audit_log_router, prefix='/audit', tags=['审计日志服务'])
|
||||
v1.include_router(coupon_router, prefix='/coupon', tags=['兑换券服务'])
|
||||
v1.include_router(points_router, prefix='/points', tags=['积分服务'])
|
||||
v1.include_router(points_router, prefix='/points', tags=['积分服务'])
|
||||
|
||||
90
backend/app/admin/api/v1/wxpay.py
Normal file
90
backend/app/admin/api/v1/wxpay.py
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi_limiter.depends import RateLimiter
|
||||
|
||||
from backend.app.admin.schema.wxpay import (
|
||||
CreateJsapiOrderRequest,
|
||||
CreateJsapiOrderResponse,
|
||||
QueryOrderResponse,
|
||||
CloseOrderResponse,
|
||||
CreateRefundRequest,
|
||||
CreateRefundResponse,
|
||||
QueryRefundResponse,
|
||||
DownloadBillResponse,
|
||||
)
|
||||
from backend.app.admin.service.wxpay_service import wxpay_service
|
||||
from backend.common.response.response_schema import response_base, ResponseSchemaModel
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/order/jsapi', summary='JSAPI/小程序下单', dependencies=[DependsJwtAuth, Depends(RateLimiter(times=10, minutes=1))])
|
||||
async def create_jsapi_order(
|
||||
request: Request,
|
||||
body: CreateJsapiOrderRequest,
|
||||
) -> ResponseSchemaModel[CreateJsapiOrderResponse]:
|
||||
result = await wxpay_service.create_jsapi_order(
|
||||
user_id=request.user.id,
|
||||
payer_openid=request.user.openid,
|
||||
amount_cents=body.amount_cents,
|
||||
description=body.description,
|
||||
)
|
||||
data = CreateJsapiOrderResponse(**result)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
# @router.get('/order/{out_trade_no}', summary='查询订单', dependencies=[DependsJwtAuth])
|
||||
# async def query_order(out_trade_no: str) -> ResponseSchemaModel[QueryOrderResponse]:
|
||||
# result = await wxpay_service.query_order(out_trade_no)
|
||||
# data = QueryOrderResponse(**result)
|
||||
# return response_base.success(data=data)
|
||||
|
||||
|
||||
# @router.post('/order/{out_trade_no}/close', summary='关闭订单', dependencies=[DependsJwtAuth])
|
||||
# async def close_order(out_trade_no: str) -> ResponseSchemaModel[CloseOrderResponse]:
|
||||
# result = await wxpay_service.close_order(out_trade_no)
|
||||
# data = CloseOrderResponse(**result)
|
||||
# return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.post('/refund', summary='申请退款', dependencies=[DependsJwtAuth])
|
||||
async def create_refund(
|
||||
request: Request,
|
||||
body: CreateRefundRequest,
|
||||
) -> ResponseSchemaModel[CreateRefundResponse]:
|
||||
result = await wxpay_service.create_refund(
|
||||
user_id=request.user.id,
|
||||
out_trade_no=body.out_trade_no,
|
||||
amount_cents=body.amount_cents,
|
||||
reason=body.reason,
|
||||
)
|
||||
data = CreateRefundResponse(**result)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.get('/refund/{out_refund_no}', summary='查询退款', dependencies=[DependsJwtAuth])
|
||||
async def query_refund(out_refund_no: str) -> ResponseSchemaModel[QueryRefundResponse]:
|
||||
result = await wxpay_service.query_refund(out_refund_no)
|
||||
data = QueryRefundResponse(**result)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
@router.post('/notify', summary='支付回调通知')
|
||||
async def pay_notify(request: Request) -> ResponseSchemaModel[dict]:
|
||||
raw = await request.body()
|
||||
timestamp = request.headers.get('Wechatpay-Timestamp', '')
|
||||
nonce = request.headers.get('Wechatpay-Nonce', '')
|
||||
signature = request.headers.get('Wechatpay-Signature', '')
|
||||
serial = request.headers.get('Wechatpay-Serial', '')
|
||||
result = await wxpay_service.handle_notify(raw, timestamp, nonce, signature, serial)
|
||||
return response_base.success(data=result)
|
||||
|
||||
|
||||
# @router.get('/bill', summary='下载交易账单', dependencies=[DependsJwtAuth])
|
||||
# async def download_bill(bill_date: str, bill_type: str = 'ALL') -> ResponseSchemaModel[DownloadBillResponse]:
|
||||
# result = await wxpay_service.download_bill(bill_date, bill_type)
|
||||
# data = DownloadBillResponse(**result)
|
||||
# return response_base.success(data=data)
|
||||
32
backend/app/admin/crud/wx_order_crud.py
Normal file
32
backend/app/admin/crud/wx_order_crud.py
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model.wx_pay import WxOrder
|
||||
|
||||
|
||||
class WxOrderCRUD(CRUDPlus[WxOrder]):
|
||||
async def add(self, db: AsyncSession, order: WxOrder) -> None:
|
||||
db.add(order)
|
||||
|
||||
async def get_by_out_trade_no(self, db: AsyncSession, out_trade_no: str) -> WxOrder | None:
|
||||
return await self.select_model_by_column(db, out_trade_no=out_trade_no)
|
||||
|
||||
async def set_prepay_id(self, db: AsyncSession, order_id: int, prepay_id: str) -> int:
|
||||
return await self.update_model(db, order_id, {'prepay_id': prepay_id})
|
||||
|
||||
async def update_trade_state(self, db: AsyncSession, order_id: int, trade_state: str) -> int:
|
||||
return await self.update_model(db, order_id, {'trade_state': trade_state})
|
||||
|
||||
async def set_transaction(self, db: AsyncSession, order_id: int, transaction_id: str) -> int:
|
||||
return await self.update_model(db, order_id, {'transaction_id': transaction_id})
|
||||
|
||||
async def close(self, db: AsyncSession, out_trade_no: str) -> int:
|
||||
stmt = update(self.model).where(self.model.out_trade_no == out_trade_no).values(trade_state='CLOSED')
|
||||
result = await db.execute(stmt)
|
||||
return result.rowcount
|
||||
|
||||
wx_order_dao: WxOrderCRUD = WxOrderCRUD(WxOrder)
|
||||
|
||||
14
backend/app/admin/crud/wx_pay_notify_crud.py
Normal file
14
backend/app/admin/crud/wx_pay_notify_crud.py
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model.wx_pay import WxPayNotifyLog
|
||||
|
||||
|
||||
class WxPayNotifyCRUD(CRUDPlus[WxPayNotifyLog]):
|
||||
async def add(self, db: AsyncSession, log: WxPayNotifyLog) -> None:
|
||||
db.add(log)
|
||||
|
||||
wx_pay_notify_dao: WxPayNotifyCRUD = WxPayNotifyCRUD(WxPayNotifyLog)
|
||||
|
||||
26
backend/app/admin/crud/wx_refund_crud.py
Normal file
26
backend/app/admin/crud/wx_refund_crud.py
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy_crud_plus import CRUDPlus
|
||||
|
||||
from backend.app.admin.model.wx_pay import WxRefund
|
||||
|
||||
|
||||
class WxRefundCRUD(CRUDPlus[WxRefund]):
|
||||
async def add(self, db: AsyncSession, refund: WxRefund) -> None:
|
||||
db.add(refund)
|
||||
|
||||
async def get_by_out_refund_no(self, db: AsyncSession, out_refund_no: str) -> WxRefund | None:
|
||||
return await self.select_model_by_column(db, out_refund_no=out_refund_no)
|
||||
|
||||
async def set_refund_id(self, db: AsyncSession, refund_id_pk: int, refund_id: str) -> int:
|
||||
return await self.update_model(db, refund_id_pk, {'refund_id': refund_id})
|
||||
|
||||
async def update_status(self, db: AsyncSession, refund_id_pk: int, status: str, raw: dict | None = None) -> int:
|
||||
payload = {'status': status}
|
||||
if raw is not None:
|
||||
payload['raw'] = raw
|
||||
return await self.update_model(db, refund_id_pk, payload)
|
||||
|
||||
wx_refund_dao: WxRefundCRUD = WxRefundCRUD(WxRefund)
|
||||
|
||||
@@ -6,4 +6,5 @@ from backend.app.admin.model.audit_log import AuditLog, DailySummary
|
||||
from backend.app.admin.model.file import File
|
||||
from backend.app.admin.model.coupon import Coupon, CouponUsage
|
||||
from backend.app.admin.model.points import Points, PointsLog
|
||||
from backend.app.ai.model import Image
|
||||
from backend.app.ai.model import Image
|
||||
from backend.app.admin.model.wx_pay import WxOrder, WxRefund, WxPayNotifyLog
|
||||
|
||||
63
backend/app/admin/model/wx_pay.py
Normal file
63
backend/app/admin/model/wx_pay.py
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import String, BigInteger, Integer, Boolean, DateTime, Text, Index
|
||||
from sqlalchemy.dialects.mysql import JSON as MySQLJSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.common.model import snowflake_id_key, Base
|
||||
|
||||
|
||||
class WxOrder(Base):
|
||||
__tablename__ = 'wx_order'
|
||||
|
||||
id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False, comment='用户ID')
|
||||
out_trade_no: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment='商户订单号')
|
||||
description: Mapped[str] = mapped_column(String(128), nullable=False, comment='订单描述')
|
||||
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False, comment='订单金额(分)')
|
||||
payer_openid: Mapped[Optional[str]] = mapped_column(String(64), default=None, index=True, comment='支付者openid')
|
||||
prepay_id: Mapped[Optional[str]] = mapped_column(String(64), default=None, index=True, comment='预支付ID')
|
||||
transaction_id: Mapped[Optional[str]] = mapped_column(String(64), default=None, index=True, comment='微信支付交易单号')
|
||||
trade_state: Mapped[str] = mapped_column(String(16), default='NOTPAY', index=True, comment='订单状态')
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_wx_order_user', 'user_id', 'trade_state'),
|
||||
{'comment': '微信支付订单'}
|
||||
)
|
||||
|
||||
|
||||
class WxRefund(Base):
|
||||
__tablename__ = 'wx_refund'
|
||||
|
||||
id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False, comment='用户ID')
|
||||
out_trade_no: Mapped[str] = mapped_column(String(64), nullable=False, comment='商户订单号')
|
||||
out_refund_no: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment='商户退款单号')
|
||||
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False, comment='退款金额(分)')
|
||||
refund_id: Mapped[Optional[str]] = mapped_column(String(64), default=None, index=True, comment='微信退款单号')
|
||||
status: Mapped[str] = mapped_column(String(16), default='PROCESSING', index=True, comment='退款状态')
|
||||
reason: Mapped[Optional[str]] = mapped_column(String(128), default=None, comment='退款原因')
|
||||
raw: Mapped[Optional[dict]] = mapped_column(MySQLJSON, default=None, comment='原始返回数据')
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_wx_refund_trade', 'out_trade_no', 'status'),
|
||||
{'comment': '微信支付退款'}
|
||||
)
|
||||
|
||||
|
||||
class WxPayNotifyLog(Base):
|
||||
__tablename__ = 'wx_pay_notify_log'
|
||||
|
||||
id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True)
|
||||
out_trade_no: Mapped[str] = mapped_column(String(64), index=True, nullable=False, comment='商户订单号')
|
||||
event_type: Mapped[str] = mapped_column(String(32), nullable=False, comment='事件类型')
|
||||
verified: Mapped[bool] = mapped_column(Boolean, default=False, comment='签名是否验证通过')
|
||||
raw_text: Mapped[Optional[str]] = mapped_column(Text, default=None, comment='回调原文')
|
||||
raw_json: Mapped[Optional[dict]] = mapped_column(MySQLJSON, default=None, comment='回调JSON')
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_wx_notify_trade', 'out_trade_no'),
|
||||
{'comment': '微信支付回调日志'}
|
||||
)
|
||||
58
backend/app/admin/schema/wxpay.py
Normal file
58
backend/app/admin/schema/wxpay.py
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.common.schema import SchemaBase
|
||||
|
||||
|
||||
class CreateJsapiOrderRequest(BaseModel):
|
||||
amount_cents: int = Field(..., description='订单金额(分)')
|
||||
description: str = Field(..., description='订单描述')
|
||||
|
||||
|
||||
class CreateJsapiOrderResponse(BaseModel):
|
||||
out_trade_no: str = Field(..., description='商户订单号')
|
||||
prepay_id: str = Field(..., description='预支付ID')
|
||||
trade_state: str = Field(..., description='订单状态')
|
||||
appId: str = Field(..., description='小程序AppID')
|
||||
timeStamp: str = Field(..., description='时间戳(秒)')
|
||||
nonceStr: str = Field(..., description='随机串')
|
||||
package: str = Field(..., description='prepay_id 包')
|
||||
signType: str = Field('RSA', description='签名类型')
|
||||
paySign: str = Field(..., description='签名')
|
||||
|
||||
|
||||
class QueryOrderResponse(BaseModel):
|
||||
out_trade_no: str = Field(..., description='商户订单号')
|
||||
transaction_id: Optional[str] = Field(None, description='微信支付交易单号')
|
||||
trade_state: str = Field(..., description='订单状态')
|
||||
amount_cents: int = Field(..., description='订单金额(分)')
|
||||
description: str = Field(..., description='订单描述')
|
||||
|
||||
|
||||
class CloseOrderResponse(BaseModel):
|
||||
out_trade_no: str = Field(..., description='商户订单号')
|
||||
trade_state: str = Field(..., description='订单状态')
|
||||
|
||||
|
||||
class CreateRefundRequest(BaseModel):
|
||||
out_trade_no: str = Field(..., description='商户订单号')
|
||||
amount_cents: int = Field(..., description='退款金额(分)')
|
||||
reason: Optional[str] = Field(None, description='退款原因')
|
||||
|
||||
|
||||
class CreateRefundResponse(BaseModel):
|
||||
out_refund_no: str = Field(..., description='商户退款单号')
|
||||
refund_id: Optional[str] = Field(None, description='微信退款单号')
|
||||
status: str = Field(..., description='退款状态')
|
||||
|
||||
|
||||
class QueryRefundResponse(CreateRefundResponse):
|
||||
pass
|
||||
|
||||
|
||||
class DownloadBillResponse(BaseModel):
|
||||
bill_date: str = Field(..., description='账单日期')
|
||||
content: str = Field(..., description='账单原文')
|
||||
@@ -1,37 +1,264 @@
|
||||
from wechatpy.pay import WeChatPay
|
||||
|
||||
from backend.app.admin.model.order import Order
|
||||
from backend.app.admin.service.usage_service import UsageService
|
||||
from backend.core.conf import settings
|
||||
from backend.app.admin.crud.order_crud import order_dao
|
||||
from backend.database.db import async_db_session
|
||||
|
||||
wxpay = WeChatPay(
|
||||
appid=settings.WECHAT_APP_ID,
|
||||
api_key=settings.WECHAT_PAY_API_KEY,
|
||||
mch_id=settings.WECHAT_MCH_ID,
|
||||
mch_cert=settings.WECHAT_PAY_CERT_PATH,
|
||||
mch_key=settings.WECHAT_PAY_KEY_PATH
|
||||
)
|
||||
|
||||
class WxPayService:
|
||||
|
||||
@staticmethod
|
||||
async def create_order(user_id: int, amount_cents: int, description: str):
|
||||
async with async_db_session.begin() as db:
|
||||
order = Order(
|
||||
user_id=user_id,
|
||||
order_type="purchase",
|
||||
amount_cents=amount_cents,
|
||||
amount_times=UsageService.calculate_purchase_times(amount_cents),
|
||||
status="pending"
|
||||
)
|
||||
await order_dao.add(db, order)
|
||||
result = wxpay.order.create(
|
||||
trade_type="JSAPI",
|
||||
body=description,
|
||||
total_fee=amount_cents,
|
||||
notify_url=settings.WECHAT_NOTIFY_URL,
|
||||
out_trade_no=str(order.id)
|
||||
)
|
||||
return result
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import httpx
|
||||
|
||||
from backend.app.admin.model.wx_pay import WxOrder, WxRefund, WxPayNotifyLog
|
||||
from backend.app.admin.crud.wx_order_crud import wx_order_dao
|
||||
from backend.app.admin.crud.wx_refund_crud import wx_refund_dao
|
||||
from backend.app.admin.crud.wx_pay_notify_crud import wx_pay_notify_dao
|
||||
from backend.core.conf import settings
|
||||
from backend.database.db import async_db_session
|
||||
from backend.utils.wechatpay_v3 import decrypt_resource, _verify_header_signature
|
||||
from wechatpayv3 import WeChatPay, WeChatPayType
|
||||
|
||||
|
||||
class WxPayService:
|
||||
@staticmethod
|
||||
def _build_wxpay_instance(notify_url: str) -> WeChatPay:
|
||||
def _read(path: str) -> str:
|
||||
with open(path, 'r') as f:
|
||||
return f.read()
|
||||
private_key = _read(settings.WX_PAY_KEY_PATH) if settings.WX_PAY_KEY_PATH else ""
|
||||
public_key = _read(settings.WX_PAY_PLATFORM_CERT_PATH) if settings.WX_PAY_PLATFORM_CERT_PATH else None
|
||||
return WeChatPay(
|
||||
wechatpay_type=WeChatPayType.JSAPI,
|
||||
mchid=settings.WX_PAY_MCH_ID or settings.WX_MCH_ID,
|
||||
private_key=private_key,
|
||||
cert_serial_no=settings.WX_PAY_SERIAL_NO,
|
||||
apiv3_key=settings.WX_PAY_APIV3_KEY,
|
||||
appid=settings.WX_SP_APPID or settings.WX_APPID,
|
||||
notify_url=notify_url,
|
||||
logger=logging.getLogger('wxpay'),
|
||||
proxy=None,
|
||||
timeout=(10, 30),
|
||||
partner_mode=False,
|
||||
public_key=public_key,
|
||||
public_key_id=settings.WX_PAY_PUB_ID or None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_result(result: object) -> dict:
|
||||
if isinstance(result, (str, bytes)):
|
||||
try:
|
||||
return json.loads(result)
|
||||
except Exception:
|
||||
return {}
|
||||
if isinstance(result, dict):
|
||||
msg = result.get('message')
|
||||
if isinstance(msg, (str, bytes)):
|
||||
try:
|
||||
return json.loads(msg)
|
||||
except Exception:
|
||||
return {}
|
||||
return result
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _safe_call(func, *args, **kwargs):
|
||||
try:
|
||||
code, result = func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logging.error(f"WeChatPay call failed: {e}")
|
||||
raise RuntimeError(f"wxpay_error: {str(e)}")
|
||||
if code != 200:
|
||||
# 尝试解析 message 以便返回更清晰的错误
|
||||
parsed = WxPayService._parse_result(result)
|
||||
logging.warning(f"WeChatPay non-200 response: code={code}, parsed={parsed}")
|
||||
raise RuntimeError(f"wxpay_code_{code}: {parsed or result}")
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def create_jsapi_order(user_id: int, amount_cents: int, description: str, payer_openid: str) -> dict:
|
||||
async with async_db_session.begin() as db:
|
||||
from backend.utils.snowflake import snowflake
|
||||
generated_id = snowflake.generate()
|
||||
order = WxOrder(
|
||||
user_id=user_id,
|
||||
out_trade_no=str(generated_id),
|
||||
description=description,
|
||||
amount_cents=amount_cents,
|
||||
payer_openid=payer_openid,
|
||||
trade_state='NOTPAY',
|
||||
)
|
||||
order.id = generated_id
|
||||
await wx_order_dao.add(db, order)
|
||||
await db.flush()
|
||||
await db.refresh(order)
|
||||
|
||||
# notify_url = f"{settings.SERVER_HOST}:{settings.SERVER_PORT}{settings.FASTAPI_API_V1_PATH}/wxpay/notify"
|
||||
notify_url = f"https://app.xhzone.cn:{settings.SERVER_PORT}{settings.FASTAPI_API_V1_PATH}/wxpay/notify"
|
||||
wxpay = WxPayService._build_wxpay_instance(notify_url)
|
||||
payer = {"openid": payer_openid}
|
||||
result = WxPayService._safe_call(
|
||||
wxpay.pay,
|
||||
description=description,
|
||||
out_trade_no=str(order.id),
|
||||
amount={"total": amount_cents, "currency": "CNY"},
|
||||
pay_type=WeChatPayType.JSAPI,
|
||||
payer=payer,
|
||||
)
|
||||
data = WxPayService._parse_result(result)
|
||||
prepay_id = data.get('prepay_id') or ''
|
||||
if prepay_id:
|
||||
await wx_order_dao.set_prepay_id(db, order.id, prepay_id)
|
||||
app_id = settings.WX_SP_APPID or settings.WX_APPID
|
||||
timestamp = str(int(datetime.now().timestamp()))
|
||||
nonce_str = str(order.id)
|
||||
package = f"prepay_id={prepay_id}"
|
||||
sign_type = "RSA"
|
||||
pay_sign = wxpay.sign([app_id, timestamp, nonce_str, package])
|
||||
return {
|
||||
'out_trade_no': str(order.id),
|
||||
'prepay_id': prepay_id,
|
||||
'trade_state': order.trade_state,
|
||||
'appId': app_id,
|
||||
'timeStamp': timestamp,
|
||||
'nonceStr': nonce_str,
|
||||
'package': package,
|
||||
'signType': sign_type,
|
||||
'paySign': pay_sign,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def query_order(out_trade_no: str) -> dict:
|
||||
async with async_db_session.begin() as db:
|
||||
order = await wx_order_dao.get_by_out_trade_no(db, out_trade_no)
|
||||
if not order:
|
||||
return {}
|
||||
notify_url = f"{settings.SERVER_HOST}:{settings.SERVER_PORT}{settings.FASTAPI_API_V1_PATH}/wxpay/notify"
|
||||
wxpay = WxPayService._build_wxpay_instance(notify_url)
|
||||
result = WxPayService._safe_call(wxpay.query, out_trade_no=out_trade_no)
|
||||
data = WxPayService._parse_result(result)
|
||||
trade_state = data.get('trade_state', 'NOTPAY')
|
||||
transaction_id = data.get('transaction_id')
|
||||
await wx_order_dao.update_trade_state(db, order.id, trade_state)
|
||||
if transaction_id:
|
||||
await wx_order_dao.set_transaction(db, order.id, transaction_id)
|
||||
return {
|
||||
'out_trade_no': out_trade_no,
|
||||
'transaction_id': transaction_id,
|
||||
'trade_state': trade_state,
|
||||
'amount_cents': order.amount_cents,
|
||||
'description': order.description,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def close_order(out_trade_no: str) -> dict:
|
||||
async with async_db_session.begin() as db:
|
||||
notify_url = f"{settings.SERVER_HOST}:{settings.SERVER_PORT}{settings.FASTAPI_API_V1_PATH}/wxpay/notify"
|
||||
wxpay = WxPayService._build_wxpay_instance(notify_url)
|
||||
try:
|
||||
WxPayService._safe_call(wxpay.close, out_trade_no=out_trade_no)
|
||||
except Exception:
|
||||
# 关单失败不影响本地标记为 CLOSED
|
||||
logging.warning(f"WeChatPay close order failed for {out_trade_no}")
|
||||
await wx_order_dao.close(db, out_trade_no)
|
||||
return {'out_trade_no': out_trade_no, 'trade_state': 'CLOSED'}
|
||||
|
||||
@staticmethod
|
||||
async def create_refund(user_id: int, out_trade_no: str, amount_cents: int, reason: Optional[str] = None) -> dict:
|
||||
async with async_db_session.begin() as db:
|
||||
from backend.utils.snowflake import snowflake
|
||||
# 使用退款记录主键 id 作为 out_refund_no
|
||||
generated_id = snowflake.generate()
|
||||
refund = WxRefund(
|
||||
user_id=user_id,
|
||||
out_trade_no=out_trade_no,
|
||||
out_refund_no=str(generated_id),
|
||||
amount_cents=amount_cents,
|
||||
reason=reason,
|
||||
)
|
||||
refund.id = generated_id
|
||||
await wx_refund_dao.add(db, refund)
|
||||
await db.flush()
|
||||
await db.refresh(refund)
|
||||
|
||||
notify_url = f"{settings.SERVER_HOST}:{settings.SERVER_PORT}{settings.FASTAPI_API_V1_PATH}/wxpay/notify"
|
||||
wxpay = WxPayService._build_wxpay_instance(notify_url)
|
||||
result = WxPayService._safe_call(
|
||||
wxpay.refund,
|
||||
out_trade_no=out_trade_no,
|
||||
out_refund_no=str(refund.id),
|
||||
amount={"refund": amount_cents, "total": amount_cents, "currency": "CNY"},
|
||||
reason=reason or "",
|
||||
)
|
||||
data = WxPayService._parse_result(result)
|
||||
refund_id = data.get('refund_id')
|
||||
status = 'REFUND' if refund_id else 'PROCESSING'
|
||||
await wx_refund_dao.update_status(db, refund.id, status, raw=data or {})
|
||||
if refund_id:
|
||||
await wx_refund_dao.set_refund_id(db, refund.id, refund_id)
|
||||
return {
|
||||
'out_refund_no': str(refund.id),
|
||||
'refund_id': refund_id,
|
||||
'status': status,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def query_refund(out_refund_no: str) -> dict:
|
||||
async with async_db_session.begin() as db:
|
||||
refund = await wx_refund_dao.get_by_out_refund_no(db, out_refund_no)
|
||||
if not refund:
|
||||
return {}
|
||||
return {
|
||||
'out_refund_no': refund.out_refund_no,
|
||||
'refund_id': refund.refund_id,
|
||||
'status': refund.status,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def handle_notify(raw_body: bytes, timestamp: str | None = None, nonce: str | None = None,
|
||||
signature: str | None = None, serial: str | None = None) -> dict:
|
||||
async with async_db_session.begin() as db:
|
||||
text = raw_body.decode('utf-8') if isinstance(raw_body, (bytes, bytearray)) else str(raw_body)
|
||||
verified = False
|
||||
if timestamp and nonce and signature:
|
||||
verified = _verify_header_signature(timestamp, nonce, text, signature, settings.WX_PAY_PLATFORM_CERT_PATH)
|
||||
body_json = json.loads(text)
|
||||
resource = body_json.get('resource') or {}
|
||||
out_trade_no = ''
|
||||
event_type = body_json.get('event_type') or 'UNKNOWN'
|
||||
if verified and resource:
|
||||
apiv3 = settings.WX_PAY_APIV3_KEY
|
||||
decrypted = decrypt_resource(resource.get('associated_data'), resource.get('nonce'), resource.get('ciphertext'), apiv3)
|
||||
out_trade_no = decrypted.get('out_trade_no') or ''
|
||||
transaction_id = decrypted.get('transaction_id')
|
||||
log = WxPayNotifyLog(
|
||||
out_trade_no=out_trade_no,
|
||||
event_type=event_type,
|
||||
verified=verified,
|
||||
raw_text=text,
|
||||
raw_json=body_json,
|
||||
)
|
||||
await wx_pay_notify_dao.add(db, log)
|
||||
|
||||
if verified and out_trade_no:
|
||||
order = await wx_order_dao.get_by_out_trade_no(db, out_trade_no)
|
||||
if order:
|
||||
await wx_order_dao.update_trade_state(db, order.id, 'SUCCESS')
|
||||
if 'transaction_id' in locals() and transaction_id:
|
||||
await wx_order_dao.set_transaction(db, order.id, transaction_id)
|
||||
return {'verified': verified}
|
||||
|
||||
@staticmethod
|
||||
async def download_bill(bill_date: str, bill_type: str = 'ALL') -> dict:
|
||||
notify_url = f"{settings.SERVER_HOST}:{settings.SERVER_PORT}{settings.FASTAPI_API_V1_PATH}/wxpay/notify"
|
||||
wxpay = WxPayService._build_wxpay_instance(notify_url)
|
||||
try:
|
||||
result = WxPayService._safe_call(wxpay.bill, bill_type='tradebill', bill_date=bill_date, tar_type='GZIP')
|
||||
data = WxPayService._parse_result(result)
|
||||
url = data.get('download_url')
|
||||
except Exception as e:
|
||||
logging.warning(f"WeChatPay bill fetch failed: {e}")
|
||||
url = None
|
||||
content = ''
|
||||
if url:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(url)
|
||||
content = r.text
|
||||
return {'bill_date': bill_date, 'content': content}
|
||||
|
||||
|
||||
wxpay_service: WxPayService = WxPayService()
|
||||
|
||||
@@ -37,8 +37,17 @@ class Settings(BaseSettings):
|
||||
WX_APPID: str
|
||||
WX_SECRET: str
|
||||
WX_MCH_ID: str = ""
|
||||
WX_PAY_MCH_ID: str = ""
|
||||
WX_PAY_PUB_ID: str = ""
|
||||
WX_PAY_CERT_PATH: str = ""
|
||||
WX_PAY_KEY_PATH: str = ""
|
||||
WX_PAY_API_KEY: str = ""
|
||||
WX_SP_APPID: str = ""
|
||||
WX_SUB_APPID: str = ""
|
||||
WX_PAY_SERIAL_NO: str = ""
|
||||
WX_PAY_APIV3_KEY: str = ""
|
||||
WX_PAY_PLATFORM_SERIAL_NO: str = ""
|
||||
WX_PAY_PLATFORM_CERT_PATH: str = ""
|
||||
|
||||
# model key
|
||||
QWEN_API_KEY: str
|
||||
@@ -103,6 +112,7 @@ class Settings(BaseSettings):
|
||||
f'{FASTAPI_API_V1_PATH}/wx/login',
|
||||
f'{FASTAPI_API_V1_PATH}/auth/login',
|
||||
f'{FASTAPI_API_V1_PATH}/auth/logout',
|
||||
f'{FASTAPI_API_V1_PATH}/wxpay/notify',
|
||||
]
|
||||
|
||||
# JWT
|
||||
@@ -202,4 +212,4 @@ def get_db_uri(settings: Settings):
|
||||
# Changed from PostgresDsn.build to manual URL construction for MySQL
|
||||
return f"mysql+asyncmy://{settings.DATABASE_USER}:{settings.DATABASE_PASSWORD}@{settings.DATABASE_HOST}:{settings.DATABASE_PORT}/{settings.DATABASE_DB_NAME}"
|
||||
|
||||
settings = get_settings()
|
||||
settings = get_settings()
|
||||
|
||||
@@ -6,11 +6,27 @@ import uvicorn
|
||||
from backend.middleware.cos_client import CosClient
|
||||
from backend.core.registrar import register_app
|
||||
from backend.core.conf import settings
|
||||
from backend.app.admin.service.wxpay_service import WxPayService
|
||||
from random import sample
|
||||
from string import ascii_letters, digits
|
||||
from wechatpayv3 import WeChatPay, WeChatPayType
|
||||
|
||||
app = register_app()
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
wxpay = WxPayService._build_wxpay_instance(f'https://app.xhzone.cn:80{settings.FASTAPI_API_V1_PATH}/wxpay/notif')
|
||||
out_trade_no = ''.join(sample(ascii_letters + digits, 8))
|
||||
description = 'demo-description'
|
||||
amount = 100
|
||||
code, message = wxpay.pay(
|
||||
description=description,
|
||||
out_trade_no=out_trade_no,
|
||||
amount={'total': amount},
|
||||
payer={"openid": 'onPN_1wptAzuLo16_0aO-iSHuxI0'},
|
||||
pay_type=WeChatPayType.JSAPI
|
||||
)
|
||||
print({'code': code, 'message': message})
|
||||
return {"Hello": f"World"}
|
||||
|
||||
|
||||
|
||||
@@ -316,6 +316,7 @@ win32-setctime==1.2.0 ; sys_platform == 'win32'
|
||||
# via loguru
|
||||
wsproto==1.2.0
|
||||
# via simple-websocket
|
||||
wechatpayv3==2.0.1
|
||||
zope-event==5.0
|
||||
# via gevent
|
||||
zope-interface==7.2
|
||||
|
||||
Reference in New Issue
Block a user