fix wxpay
This commit is contained in:
@@ -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)
|
||||
|
||||
14
backend/app/admin/crud/points_alloc_crud.py
Normal file
14
backend/app/admin/crud/points_alloc_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.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)
|
||||
@@ -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:
|
||||
|
||||
30
backend/app/admin/crud/points_lot_crud.py
Normal file
30
backend/app/admin/crud/points_lot_crud.py
Normal 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)
|
||||
@@ -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': '积分消费分摊记录'}
|
||||
)
|
||||
|
||||
@@ -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='退款原因')
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user