fix wxpay

This commit is contained in:
Felix
2025-12-08 10:40:22 +08:00
parent fb1fa2d0f4
commit 0de8310573
11 changed files with 504 additions and 52 deletions

View File

@@ -7,6 +7,7 @@ from backend.app.admin.schema.wxpay import (
CreateJsapiOrderRequest,
CreateJsapiOrderResponse,
QueryOrderResponse,
OrderDetailsResponse,
CloseOrderResponse,
CreateRefundRequest,
CreateRefundResponse,
@@ -16,6 +17,7 @@ from backend.app.admin.schema.wxpay import (
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
from backend.app.admin.schema.wxpay import RefundAmountResponse, OrderListResponse, OrderItem
router = APIRouter()
@@ -35,14 +37,13 @@ async def create_jsapi_order(
return response_base.success(data=data)
# @router.get('/order/{out_trade_no}', summary='查询订单', dependencies=[DependsJwtAuth])
@router.get('/order/{out_trade_no}', summary='查询订单')
@router.get('/order/{out_trade_no}', summary='查询订单', dependencies=[DependsJwtAuth])
# @router.get('/order/{out_trade_no}', summary='查询订单')
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)
@@ -72,6 +73,13 @@ async def query_refund(out_refund_no: str) -> ResponseSchemaModel[QueryRefundRes
return response_base.success(data=data)
@router.get('/refund/{out_refund_no}/amount', summary='退款金额试算', dependencies=[DependsJwtAuth])
# @router.get('/refund/{out_trade_no}/amount', summary='退款金额试算')
async def compute_refund_amount(out_trade_no: str) -> ResponseSchemaModel[RefundAmountResponse]:
d = await wxpay_service.compute_refund_amount_for_out_trade(out_trade_no)
return response_base.success(data=RefundAmountResponse(**d))
@router.post('/notify', summary='支付回调通知')
async def pay_notify(request: Request) -> ResponseSchemaModel[dict]:
raw = await request.body()
@@ -87,6 +95,7 @@ async def pay_notify(request: Request) -> ResponseSchemaModel[dict]:
@router.post('/notify/refund', summary='退款回调通知')
async def refund_notify(request: Request) -> ResponseSchemaModel[dict]:
raw = await request.body()
print('pay_notify raw: ',raw)
timestamp = request.headers.get('Wechatpay-Timestamp', '')
nonce = request.headers.get('Wechatpay-Nonce', '')
signature = request.headers.get('Wechatpay-Signature', '')
@@ -95,6 +104,19 @@ async def refund_notify(request: Request) -> ResponseSchemaModel[dict]:
return response_base.success(data=result)
@router.post('/order/list', summary='订单列表', dependencies=[DependsJwtAuth])
async def list_orders(request: Request, page: int = 1, size: int = 20) -> ResponseSchemaModel[OrderListResponse]:
d = await wxpay_service.list_orders_for_user(request.user.id, page, size)
data = OrderListResponse(**d)
return response_base.success(data=data)
@router.get('/order/{out_trade_no}/details', summary='查询订单详情(含退款信息)', dependencies=[DependsJwtAuth])
async def query_order_details(out_trade_no: str) -> ResponseSchemaModel[OrderDetailsResponse]:
result = await wxpay_service.query_order_details(out_trade_no)
data = OrderDetailsResponse(**result)
return response_base.success(data=data)
# @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)

View 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.points import PointsConsumptionAlloc
class PointsConsumptionAllocCRUD(CRUDPlus[PointsConsumptionAlloc]):
async def add(self, db: AsyncSession, alloc: PointsConsumptionAlloc) -> None:
db.add(alloc)
points_alloc_dao: PointsConsumptionAllocCRUD = PointsConsumptionAllocCRUD(PointsConsumptionAlloc)

View File

@@ -98,6 +98,11 @@ class PointsDao(CRUDPlus[Points]):
result = await db.execute(stmt)
return result.rowcount > 0
async def add_refunded_atomic(self, db: AsyncSession, user_id: int, amount: int) -> bool:
stmt = update(Points).where(Points.user_id == user_id).values(total_refunded=Points.total_refunded + amount)
result = await db.execute(stmt)
return result.rowcount > 0
class PointsLogDao(CRUDPlus[PointsLog]):
async def add_log(self, db: AsyncSession, log_data: Dict[str, Any]) -> PointsLog:

View File

@@ -0,0 +1,30 @@
#!/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.points import PointsLot
class PointsLotCRUD(CRUDPlus[PointsLot]):
async def add(self, db: AsyncSession, lot: PointsLot) -> None:
db.add(lot)
async def get_by_order(self, db: AsyncSession, order_id: int) -> PointsLot | None:
stmt = select(self.model).where(self.model.order_id == order_id).limit(1)
result = await db.execute(stmt)
return result.scalar()
async def list_available(self, db: AsyncSession, user_id: int) -> list[PointsLot]:
stmt = select(self.model).where(self.model.user_id == user_id, self.model.points_remaining > 0).order_by(self.model.created_time)
result = await db.execute(stmt)
return list(result.scalars().all())
async def deduct_from_lot(self, db: AsyncSession, lot_id: int, amount: int) -> int:
stmt = update(self.model).where(self.model.id == lot_id).values(points_remaining=self.model.points_remaining - amount)
result = await db.execute(stmt)
return result.rowcount
points_lot_dao: PointsLotCRUD = PointsLotCRUD(PointsLot)

View File

@@ -14,8 +14,10 @@ class Points(Base):
id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False, comment='关联的用户ID')
balance: Mapped[int] = mapped_column(BigInteger, default=0, comment='当前积分余额')
frozen_balance: Mapped[int] = mapped_column(BigInteger, default=0, comment='冻结积分余额')
total_earned: Mapped[int] = mapped_column(BigInteger, default=0, comment='累计获得积分')
total_spent: Mapped[int] = mapped_column(BigInteger, default=0, comment='累计消费积分')
total_refunded: Mapped[int] = mapped_column(BigInteger, default=0, comment='累计退款积分')
# 索引优化
__table_args__ = (
@@ -29,7 +31,7 @@ class PointsLog(Base):
id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment='用户ID')
action: Mapped[str] = mapped_column(String(32), comment='积分变更类型system_gift/recharge/purchase/etc')
action: Mapped[str] = mapped_column(String(32), comment='积分变更类型system_gift/recharge/spend/refund_freeze/refund_unfreeze/refund_deduct')
amount: Mapped[int] = mapped_column(BigInteger, comment='变动数量')
balance_after: Mapped[int] = mapped_column(BigInteger, comment='变动后余额')
related_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, default=None, comment='关联ID')
@@ -41,4 +43,38 @@ class PointsLog(Base):
Index('idx_points_log_related', 'related_id'),
Index('idx_points_log_user_time', 'user_id', 'created_time'),
{'comment': '积分变动日志表'}
)
)
class PointsLot(Base):
__tablename__ = 'points_lot'
id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False, comment='用户ID')
order_id: Mapped[int | None] = mapped_column(BigInteger, ForeignKey('wx_order.id'), nullable=True, comment='订单ID')
points_total: Mapped[int] = mapped_column(BigInteger, nullable=False, comment='批次总积分')
points_remaining: Mapped[int] = mapped_column(BigInteger, nullable=False, comment='批次剩余积分')
amount_cents_total: Mapped[int] = mapped_column(BigInteger, nullable=False, comment='批次总金额(分)')
__table_args__ = (
Index('idx_points_lot_user', 'user_id'),
Index('idx_points_lot_order', 'order_id'),
{'comment': '积分批次(对应每笔成功订单)'}
)
class PointsConsumptionAlloc(Base):
__tablename__ = 'points_consumption_alloc'
id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False, comment='用户ID')
lot_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('points_lot.id'), nullable=False, comment='批次ID')
spend_log_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('points_log.id'), nullable=False, comment='消费日志ID')
points: Mapped[int] = mapped_column(BigInteger, nullable=False, comment='分摊积分数量')
__table_args__ = (
Index('idx_points_alloc_user', 'user_id'),
Index('idx_points_alloc_lot', 'lot_id'),
Index('idx_points_alloc_spend', 'spend_log_id'),
{'comment': '积分消费分摊记录'}
)

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Optional
from pydantic import BaseModel, Field
from datetime import datetime
from pydantic import BaseModel, Field
@@ -31,6 +32,41 @@ class QueryOrderResponse(BaseModel):
description: str = Field(..., description='订单描述')
class OrderDetailsResponse(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='订单描述')
can_refund: Optional[bool] = Field(None, description='是否可退款')
refund_status: Optional[str] = Field(None, description='最近退款状态')
refundable_amount_cents: Optional[int] = Field(None, description='当前可退金额(分)')
refundable_amount_points: Optional[int] = Field(None, description='当前可退冻结积分数量')
class RefundAmountResponse(BaseModel):
order_id: Optional[str] = Field(None, description='订单ID')
refundable_points: int = Field(..., description='可退积分')
refundable_amount_cents: int = Field(..., description='可退金额(分)')
amount_per_point: float = Field(..., description='每积分金额(分)')
class OrderItem(BaseModel):
id: str = Field(..., description='订单ID')
created_time: str = Field(..., description='创建时间')
amount_cents: int = Field(..., description='订单金额(分)')
points: Optional[int] = Field(None, description='积分数')
product_id: Optional[str] = Field(None, description='商品ID')
refund_status: Optional[str] = Field(None, description='最近退款状态')
class OrderListResponse(BaseModel):
items: list[OrderItem] = Field(..., description='订单列表')
total: int = Field(..., description='总数')
page: int = Field(..., description='页码')
size: int = Field(..., description='每页数量')
class CloseOrderResponse(BaseModel):
out_trade_no: str = Field(..., description='商户订单号')
trade_state: str = Field(..., description='订单状态')
@@ -38,7 +74,7 @@ class CloseOrderResponse(BaseModel):
class CreateRefundRequest(BaseModel):
out_trade_no: str = Field(..., description='商户订单号')
amount_cents: int = Field(..., description='退款金额(分)')
amount_cents: Optional[int] = Field(..., description='退款金额(分)')
reason: Optional[str] = Field(None, description='退款原因')

View File

@@ -2,11 +2,17 @@ from typing import Optional
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.points_crud import points_dao, points_log_dao
from backend.app.admin.model.points import Points
from backend.app.admin.crud.points_lot_crud import points_lot_dao
from backend.app.admin.crud.points_alloc_crud import points_alloc_dao
from backend.app.admin.model.points import PointsConsumptionAlloc
from backend.app.admin.model.points import Points, PointsLot
from backend.database.db import async_db_session
from backend.common.const import (
POINTS_ACTION_RECHARGE,
POINTS_ACTION_SPEND
POINTS_ACTION_SPEND,
POINTS_ACTION_REFUND_FREEZE,
POINTS_ACTION_REFUND_UNFREEZE,
POINTS_ACTION_REFUND_DEDUCT,
)
@@ -141,12 +147,11 @@ class PointsService:
return True
async with async_db_session() as db:
# 直接获取用户积分余额(不再检查过期积分)
points_account = await points_dao.get_by_user_id(db, user_id)
if not points_account:
return False
return points_account.balance >= required_amount
available = max(0, (points_account.balance or 0) - (points_account.frozen_balance or 0))
return available >= required_amount
@staticmethod
async def deduct_points_with_db(user_id: int, amount: int, db: AsyncSession, related_id: Optional[int] = None, details: Optional[dict] = None, action: Optional[str] = None) -> bool:
@@ -167,32 +172,48 @@ class PointsService:
if amount <= 0:
raise ValueError("积分数量必须大于0")
# 直接获取用户积分账户(不再检查过期积分)
points_account = await points_dao.get_by_user_id(db, user_id)
if not points_account or points_account.balance < amount:
if not points_account:
return False
available = max(0, (points_account.balance or 0) - (points_account.frozen_balance or 0))
if available < amount:
return False
current_balance = points_account.balance
# 原子性扣减积分
# 批次扣减FIFO
remaining = amount
lot_items = await points_lot_dao.list_available(db, user_id)
alloc_details = []
for lot in lot_items:
if remaining <= 0:
break
take = min(lot.points_remaining, remaining)
if take > 0:
await points_lot_dao.deduct_from_lot(db, lot.id, take)
alloc_details.append({"lot_id": lot.id, "order_id": lot.order_id, "points": take})
remaining -= take
result = await points_dao.deduct_balance_atomic(db, user_id, amount)
if not result:
return False
# 记录积分变动日志
new_balance = current_balance - amount
# 如果action参数为空则默认使用POINTS_ACTION_SPEND
# 记录积分变动日志
if action is None:
action = POINTS_ACTION_SPEND
await points_log_dao.add_log(db, {
spend_log = await points_log_dao.add_log(db, {
"user_id": user_id,
"action": action,
"amount": amount,
"balance_after": new_balance,
"related_id": related_id,
"details": details
"details": details or {}
})
if action == POINTS_ACTION_REFUND_DEDUCT:
await points_dao.add_refunded_atomic(db, user_id, amount)
# 持久化分摊记录
for a in alloc_details:
alloc = PointsConsumptionAlloc(user_id=user_id, lot_id=a["lot_id"], spend_log_id=spend_log.id, points=a["points"])
await points_alloc_dao.add(db, alloc)
return True
@@ -207,10 +228,22 @@ class PointsService:
为新用户初始化积分账户根据新需求直接在balance中增加初始积分
"""
if db is not None:
# Use the provided session (for nested transactions)
points_account = await points_dao.get_by_user_id(db, user_id)
if not points_account:
points_account = await points_dao.create_new_user_account(db, user_id)
# 为新用户创建免费试用批次金额为0
from backend.common.const import FREE_TRIAL_BALANCE
free_lot_exists = await points_lot_dao.list_available(db, user_id)
# 仅在没有任何批次时创建免费批次
if not free_lot_exists:
free_lot = PointsLot(
user_id=user_id,
order_id=None,
points_total=FREE_TRIAL_BALANCE,
points_remaining=FREE_TRIAL_BALANCE,
amount_cents_total=0,
)
await points_lot_dao.add(db, free_lot)
return points_account
else:
# Create a new transaction (standalone usage)
@@ -218,6 +251,17 @@ class PointsService:
points_account = await points_dao.get_by_user_id(db, user_id)
if not points_account:
points_account = await points_dao.create_new_user_account(db, user_id)
from backend.common.const import FREE_TRIAL_BALANCE
free_lot_exists = await points_lot_dao.list_available(db, user_id)
if not free_lot_exists:
free_lot = PointsLot(
user_id=user_id,
order_id=None,
points_total=FREE_TRIAL_BALANCE,
points_remaining=FREE_TRIAL_BALANCE,
amount_cents_total=0,
)
await points_lot_dao.add(db, free_lot)
return points_account
@staticmethod
@@ -229,14 +273,100 @@ class PointsService:
points_account = await points_dao.get_by_user_id(db, user_id)
if not points_account:
return {}
available_balance = max(0, points_account.balance - frozen_balance)
available_balance = max(0, (points_account.balance or 0) - (points_account.frozen_balance or 0))
return {
"balance": points_account.balance,
"available_balance": available_balance,
"total_purchased": points_account.total_earned, # Using total_earned as equivalent to total_purchased
"frozen_balance": points_account.frozen_balance,
"total_purchased": points_account.total_earned,
"total_refunded": getattr(points_account, 'total_refunded', 0),
}
@staticmethod
async def add_points_from_order_with_db(db: AsyncSession, user_id: int, order_id: int, points: int, amount_cents: int) -> bool:
if points <= 0:
return False
points_account = await points_dao.get_by_user_id(db, user_id)
if not points_account:
points_account = await points_dao.create_user_points(db, user_id)
current_balance = points_account.balance
ok = await points_dao.add_points_atomic(db, user_id, points)
if not ok:
return False
lot = PointsLot(user_id=user_id, order_id=order_id, points_total=points, points_remaining=points, amount_cents_total=amount_cents)
await points_lot_dao.add(db, lot)
await points_log_dao.add_log(db, {
"user_id": user_id,
"action": POINTS_ACTION_RECHARGE,
"amount": points,
"balance_after": current_balance + points,
"related_id": order_id,
"details": {"order_id": order_id, "amount_cents": amount_cents}
})
return True
@staticmethod
async def freeze_points_for_order(db: AsyncSession, user_id: int, order_id: int, points_to_freeze: int) -> bool:
if points_to_freeze <= 0:
return False
# increase frozen_balance if available
acct = await points_dao.get_by_user_id(db, user_id)
if not acct:
return False
available = max(0, (acct.balance or 0) - (acct.frozen_balance or 0))
if available < points_to_freeze:
points_to_freeze = available
from sqlalchemy import update
stmt = update(Points).where(Points.user_id == user_id).values(frozen_balance=Points.frozen_balance + points_to_freeze)
res = await db.execute(stmt)
if res.rowcount <= 0:
return False
# 重新获取最新账户,计算可用余额
acct2 = await points_dao.get_by_user_id(db, user_id)
available_after = max(0, (acct2.balance or 0) - (acct2.frozen_balance or 0))
await points_log_dao.add_log(db, {
"user_id": user_id,
"action": POINTS_ACTION_REFUND_FREEZE,
"amount": points_to_freeze,
"balance_after": acct2.balance,
"related_id": order_id,
"details": {"order_id": order_id, "frozen_balance_after": acct2.frozen_balance, "available_balance_after": available_after}
})
return True
@staticmethod
async def unfreeze_points_for_order(db: AsyncSession, user_id: int, order_id: int, points_to_unfreeze: int, deduct: bool = False) -> bool:
if points_to_unfreeze <= 0:
return False
acct = await points_dao.get_by_user_id(db, user_id)
if not acct:
return False
from sqlalchemy import update
# Adjust frozen
stmt = update(Points).where(Points.user_id == user_id, Points.frozen_balance >= points_to_unfreeze).values(frozen_balance=Points.frozen_balance - points_to_unfreeze)
res = await db.execute(stmt)
if res.rowcount <= 0:
return False
action = POINTS_ACTION_REFUND_UNFREEZE
# 重新获取最新账户,计算可用余额
acct2 = await points_dao.get_by_user_id(db, user_id)
available_after = max(0, (acct2.balance or 0) - (acct2.frozen_balance or 0))
details = {"order_id": order_id, "frozen_balance_after": acct2.frozen_balance, "available_balance_after": available_after}
if deduct:
# deduct from lots and balance
await PointsService.deduct_points_with_db(user_id, points_to_unfreeze, db, related_id=order_id, details={"order_id": order_id}, action=POINTS_ACTION_REFUND_DEDUCT)
action = POINTS_ACTION_REFUND_DEDUCT
# 重新获取最新账户,计算扣减后的余额
acct3 = await points_dao.get_by_user_id(db, user_id)
await points_log_dao.add_log(db, {
"user_id": user_id,
"action": action,
"amount": points_to_unfreeze,
"balance_after": acct3.balance,
"related_id": order_id,
"details": details
})
return True
points_service:PointsService = PointsService()

View File

@@ -18,9 +18,10 @@ class ProductService:
@staticmethod
async def init_products(items: List[dict] | None = None) -> int:
default = [
{"title": "100积分", "description": "充值100积分", "points": 100, "amount_cents": 1, "one_time": False},
# {"title": "300积分", "description": "充值300积分", "points": 300, "amount_cents": 300, "one_time": False},
{"title": "1000积分首购", "description": "首购礼包,仅限一次", "points": 1000, "amount_cents": 1, "one_time": True},
{"title": "500积分", "description": "仅限一次", "points": 500, "amount_cents": 100, "one_time": True},
{"title": "100积分", "description": "充值100积分", "points": 100, "amount_cents": 100, "one_time": False},
{"title": "500积分", "description": "充值500积分", "points": 500, "amount_cents": 500, "one_time": False},
{"title": "100积分", "description": "测试100积分", "points": 100, "amount_cents": 1, "one_time": False},
]
payload = items or default
async with async_db_session.begin() as db:

View File

@@ -168,6 +168,132 @@ class WxPayService:
'description': order.description,
}
@staticmethod
async def query_order_details(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 {}
# 最近一次退款状态
from sqlalchemy import select as sa_select2
latest_refund = await db.execute(
sa_select2(WxRefund).where(WxRefund.out_trade_no == out_trade_no).order_by(WxRefund.created_time.desc()).limit(1)
)
latest_refund_obj = latest_refund.scalar_one_or_none()
can_refund = bool(order.trade_state == 'SUCCESS')
# 使用通用试算方法(批次剩余减冻结)作为退款前检查
d = await WxPayService.compute_refund_amount_for_out_trade(out_trade_no)
refundable_amount_cents = int(d.get('refundable_amount_cents') or 0)
refundable_amount_points = int(d.get('refundable_points') or 0)
return {
'out_trade_no': out_trade_no,
'transaction_id': getattr(order, 'transaction_id', None),
'trade_state': order.trade_state,
'amount_cents': order.amount_cents,
'description': order.description,
'can_refund': can_refund,
'refund_status': (latest_refund_obj.status if latest_refund_obj else None),
'refundable_amount_cents': refundable_amount_cents,
'refundable_amount_points': refundable_amount_points,
}
@staticmethod
async def compute_refund_amount_for_out_trade(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 {
'refundable_points': 0,
'refundable_amount_cents': 0,
'amount_per_point': 0,
}
return await WxPayService._compute_refund_amount_for_order(db, order)
@staticmethod
async def _compute_refund_amount_for_order(db, order: WxOrder) -> dict:
from backend.app.admin.crud.points_lot_crud import points_lot_dao
lot = await points_lot_dao.get_by_order(db, order.id)
if not lot:
return {
'order_id': str(order.id),
'refundable_points': 0,
'refundable_amount_cents': 0,
'amount_per_point': 0,
}
from backend.app.admin.model.points import PointsLog
from sqlalchemy import select, func
s = await db.execute(
select(func.coalesce(func.sum(PointsLog.amount), 0)).where(
PointsLog.user_id == order.user_id,
PointsLog.related_id == order.id,
PointsLog.action == 'refund_freeze'
)
)
frozen_points = int(s.scalar() or 0)
refundable_points = max(0, lot.points_remaining - frozen_points)
amt_per_point = (lot.amount_cents_total / lot.points_total) if lot.points_total > 0 else 0
refundable_amount = int(refundable_points * amt_per_point)
return {
'order_id': str(order.id),
'refundable_points': refundable_points,
'refundable_amount_cents': refundable_amount,
'amount_per_point': amt_per_point,
}
@staticmethod
async def list_orders_for_user(user_id: int, page: int, size: int) -> dict:
async with async_db_session() as db:
from sqlalchemy import select, func, exists
offset = max(0, (page - 1) * size)
refund_success_expr = exists(
select(WxRefund.id).where(
WxRefund.out_trade_no == WxOrder.out_trade_no,
WxRefund.status == 'REFUND'
)
).label('refund_success')
stmt = (
select(WxOrder, refund_success_expr)
.where(
WxOrder.user_id == user_id,
WxOrder.trade_state == 'SUCCESS',
WxOrder.points_granted == True,
)
.order_by(WxOrder.created_time.desc())
.offset(offset)
.limit(size)
)
result = await db.execute(stmt)
rows = result.all()
count_stmt = (
select(func.count())
.where(
WxOrder.user_id == user_id,
WxOrder.trade_state == 'SUCCESS',
WxOrder.points_granted == True,
)
)
total = int((await db.execute(count_stmt)).scalar() or 0)
items = []
for o, refund_success in rows:
items.append({
'id': str(o.id),
'created_time': o.created_time.isoformat(),
'amount_cents': o.amount_cents,
'points': o.points,
'product_id': str(o.product_id),
'refund_status': ('REFUND' if refund_success else None),
})
return {
'items': items,
'total': total,
'page': page,
'size': size,
}
@staticmethod
async def close_order(out_trade_no: str) -> dict:
async with async_db_session.begin() as db:
@@ -187,11 +313,20 @@ class WxPayService:
from backend.utils.snowflake import snowflake
# 使用退款记录主键 id 作为 out_refund_no
generated_id = snowflake.generate()
compute = await WxPayService.compute_refund_amount_for_out_trade(out_trade_no)
max_refund = int(compute.get('refundable_amount_cents') or 0)
amt_per_point = float(compute.get('amount_per_point') or 0)
final_amount = min(amount_cents or 0, max_refund)
points_to_freeze = int(final_amount / amt_per_point) if amt_per_point > 0 else 0
from backend.app.admin.service.points_service import points_service
order_obj = await wx_order_dao.get_by_out_trade_no(db, out_trade_no)
if order_obj:
await points_service.freeze_points_for_order(db, order_obj.user_id, order_obj.id, points_to_freeze)
refund = WxRefund(
user_id=user_id,
out_trade_no=out_trade_no,
out_refund_no=str(generated_id),
amount_cents=amount_cents,
amount_cents=final_amount,
reason=reason,
)
refund.id = generated_id
@@ -200,12 +335,13 @@ class WxPayService:
await db.refresh(refund)
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)
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"},
amount={"refund": final_amount, "total": final_amount, "currency": "CNY"},
reason=reason or "",
)
data = WxPayService._parse_result(result)
@@ -302,7 +438,7 @@ class WxPayService:
return
from backend.app.admin.service.points_service import points_service
try:
ok = await points_service.add_points(order.user_id, order.points, related_id=order.id, details={"product_id": order.product_id, "order_id": order.id}, action=None)
ok = await points_service.add_points_from_order_with_db(db, order.user_id, order.id, order.points, order.amount_cents)
if ok:
await wx_order_dao.update_model(db, order_id, {'points_granted': True})
# 成功发放后记录统一兼容的日志
@@ -318,8 +454,7 @@ class WxPayService:
logging.error(f"Grant points task failed for order {order_id}: {e}")
@staticmethod
async def handle_refund_notify(raw_body: bytes, timestamp: str | None = None, nonce: str | None = None,
signature: str | None = None, serial: str | None = None) -> dict:
async def handle_refund_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
@@ -350,6 +485,32 @@ class WxPayService:
'REFUND.SUCCESS',
)
)
elif verified and decrypted and status:
if status.upper() in {'CLOSED','ABNORMAL','FAIL','REFUND'}:
out_trade_no = decrypted.get('out_trade_no')
async with async_db_session.begin() as db2:
order = await wx_order_dao.get_by_out_trade_no(db2, out_trade_no)
if order:
from backend.app.admin.model.points import PointsLog
from sqlalchemy import select, func
s_freeze = await db2.execute(
select(func.coalesce(func.sum(PointsLog.amount), 0)).where(
PointsLog.user_id == order.user_id,
PointsLog.related_id == order.id,
PointsLog.action == 'refund_freeze'
)
)
s_unfreeze = await db2.execute(
select(func.coalesce(func.sum(PointsLog.amount), 0)).where(
PointsLog.user_id == order.user_id,
PointsLog.related_id == order.id,
PointsLog.action.in_(['refund_unfreeze','refund_deduct'])
)
)
frozen_points = int(s_freeze.scalar() or 0) - int(s_unfreeze.scalar() or 0)
if frozen_points > 0:
from backend.app.admin.service.points_service import points_service
await points_service.unfreeze_points_for_order(db2, order.user_id, order.id, frozen_points, deduct=False)
return {'verified': verified}
@staticmethod
@@ -365,25 +526,38 @@ class WxPayService:
return
if getattr(refund, 'points_deducted', False):
return
from backend.app.admin.crud.points_crud import points_log_dao
from backend.common.const import POINTS_ACTION_SPEND
exists = await points_log_dao.has_log_by_related(db, order.user_id, refund.id, POINTS_ACTION_SPEND)
if exists:
await wx_refund_dao.update_model(db, refund.id, {'points_deducted': True})
return
from backend.app.admin.service.points_service import points_service
try:
ok = await points_service.deduct_points(order.user_id, order.points, related_id=refund.id, details={"order_id": order.id, "product_id": order.product_id}, action=POINTS_ACTION_SPEND)
if ok:
await wx_refund_dao.update_model(db, refund.id, {'points_deducted': True})
log = WxPayNotifyLog(
out_trade_no=out_refund_no,
event_type=event_type or ('REFUND.QUERY.SUCCESS' if source == 'query' else 'REFUND.SUCCESS'),
verified=verified,
raw_text=raw_text,
raw_json=raw_json,
# 计算尚未解冻的冻结积分(本订单维度)
from backend.app.admin.model.points import PointsLog
from sqlalchemy import select, func
s_freeze = await db.execute(
select(func.coalesce(func.sum(PointsLog.amount), 0)).where(
PointsLog.user_id == order.user_id,
PointsLog.related_id == order.id,
PointsLog.action == 'refund_freeze'
)
await wx_pay_notify_dao.add(db, log)
)
s_unfreeze = await db.execute(
select(func.coalesce(func.sum(PointsLog.amount), 0)).where(
PointsLog.user_id == order.user_id,
PointsLog.related_id == order.id,
PointsLog.action.in_(['refund_unfreeze','refund_deduct'])
)
)
frozen_points = int(s_freeze.scalar() or 0) - int(s_unfreeze.scalar() or 0)
if frozen_points > 0:
# 完成时解冻并扣减对应积分(按冻结量)
await points_service.unfreeze_points_for_order(db, order.user_id, order.id, frozen_points, deduct=True)
await wx_refund_dao.update_model(db, refund.id, {'points_deducted': True})
log = WxPayNotifyLog(
out_trade_no=out_refund_no,
event_type=event_type or ('REFUND.QUERY.SUCCESS' if source == 'query' else 'REFUND.SUCCESS'),
verified=verified,
raw_text=raw_text,
raw_json=raw_json,
)
await wx_pay_notify_dao.add(db, log)
except Exception as e:
logging.error(f"Deduct points task failed for refund {out_refund_no}: {e}")

View File

@@ -13,7 +13,10 @@ POINTS_ACTION_SPEECH_ASSESSMENT = "speech_assessment"
POINTS_ACTION_RECHARGE = "recharge"
POINTS_ACTION_COUPON = "coupon"
POINTS_ACTION_SPEND = "spend"
POINTS_ACTION_REFUND_FREEZE = "refund_freeze"
POINTS_ACTION_REFUND_UNFREEZE = "refund_unfreeze"
POINTS_ACTION_REFUND_DEDUCT = "refund_deduct"
API_TYPE_RECOGNITION = 'recognition'
FREE_TRIAL_BALANCE = 30
FREE_TRIAL_BALANCE = 30

View File

@@ -29,6 +29,7 @@ from backend.database.redis import redis_client
from backend.utils.serializers import select_as_dict
from backend.utils.snowflake import snowflake
from backend.utils.timezone import timezone
from backend.common.log import log as logger
async def wx_openid_authentication(openid: str, unionid: str) -> GetWxUserInfoWithRelationDetail: