团队积分V1
This commit is contained in:
@@ -30,9 +30,12 @@ from app.enums.credit_record import (
|
||||
)
|
||||
from app.services.credit_record_meta_service import CreditRecordMeta, build_recharge_meta
|
||||
from app.services.credits import add_credits, deduct_credits
|
||||
from app.services.credit.product_service import product_to_dict
|
||||
from app.services.credit.subscription_service import fulfill_payment_product, revoke_payment_order_credits
|
||||
from app.services.credit.upgrade_service import quote_and_reserve_product_purchase, release_upgrade_reservation
|
||||
from app.enums.common import PaymentOrderSourceEnum
|
||||
from app.enums.credit_product import CreditProductType
|
||||
from app.models.team import Team
|
||||
from app.services.credit.locking import acquire_team_business_lock, acquire_user_credit_lock
|
||||
from app.services.credit.product_service import ensure_repeat_purchase_allowed, quote_product, resolve_team_purchase_context
|
||||
from app.services.credit.subscription_service import fulfill_payment_product
|
||||
from app.services.credit.utils import to_credit_decimal, utc_now
|
||||
from app.utils.id_gen import generate_id, generate_order_no
|
||||
|
||||
@@ -259,8 +262,6 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool
|
||||
return False
|
||||
|
||||
order.status = "cancelled"
|
||||
if order.upgrade_period_ids_json:
|
||||
await release_upgrade_reservation(db, order=order, released_at=utc_now())
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
|
||||
@@ -334,10 +335,6 @@ async def expire_all_pending_orders(db: AsyncSession) -> int:
|
||||
log_user_id = str(order.user_id)
|
||||
log_amount = order.amount
|
||||
order.status = "cancelled"
|
||||
if order.upgrade_period_ids_json:
|
||||
await release_upgrade_reservation(
|
||||
db, order=order, released_at=utc_now()
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
expired_count += 1
|
||||
@@ -420,9 +417,14 @@ async def create_recharge_order(
|
||||
method: str = "wechat",
|
||||
*,
|
||||
product_id: str | None = None,
|
||||
quantity: int = 1,
|
||||
request_time: datetime | None = None,
|
||||
) -> PaymentOrder:
|
||||
"""创建支付订单;支付渠道流程保持原样,仅增加积分商品快照和订阅升级预留。"""
|
||||
"""创建线上支付订单。
|
||||
|
||||
商品订单在创建时冻结商品、定价和数量快照;之后商品改价、下架或软删除均不改变订单合同。
|
||||
个人/团队订阅存在未完成订单时全局阻止再次创建订阅订单。
|
||||
"""
|
||||
db_configs = await _get_payment_configs(db)
|
||||
mock_mode = _is_mock_mode(db_configs)
|
||||
if not mock_mode:
|
||||
@@ -434,120 +436,184 @@ async def create_recharge_order(
|
||||
raise ValueError("支付宝支付未完成配置,请联系管理员")
|
||||
elif method == "wechat":
|
||||
required_configs = [
|
||||
"payment_wechat_appid",
|
||||
"payment_wechat_mch_id",
|
||||
"payment_wechat_private_key",
|
||||
"payment_wechat_cert_serial_no",
|
||||
"payment_wechat_api_v3_key",
|
||||
"payment_wechat_appid", "payment_wechat_mch_id", "payment_wechat_private_key",
|
||||
"payment_wechat_cert_serial_no", "payment_wechat_api_v3_key",
|
||||
]
|
||||
missing_configs = [key for key in required_configs if not db_configs.get(key)]
|
||||
if missing_configs:
|
||||
raise ValueError(f"微信支付未完成配置,缺少: {', '.join(missing_configs)},请联系管理员")
|
||||
else:
|
||||
raise ValueError("不支持的线上支付方式")
|
||||
|
||||
checked_at = request_time or utc_now()
|
||||
await acquire_user_credit_lock(db, user_id)
|
||||
user_result = await db.execute(select(User).where(User.id == user_id).limit(1))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
order_id = generate_id()
|
||||
order_no = generate_order_no()
|
||||
product: CreditProduct | None = None
|
||||
quote = None
|
||||
final_price = to_credit_decimal(price)
|
||||
total_credits = to_credit_decimal(float(credits or 0) + float(bonus_credits or 0))
|
||||
snapshot = None
|
||||
product_type = None
|
||||
team_id_snapshot = None
|
||||
quoted_unit = final_price
|
||||
quoted_amount = final_price
|
||||
actual_unit = final_price
|
||||
purchase_scene = "legacy_recharge"
|
||||
price_type = "regular"
|
||||
|
||||
if product_id:
|
||||
product_result = await db.execute(
|
||||
select(CreditProduct)
|
||||
.where(
|
||||
CreditProduct.id == product_id,
|
||||
CreditProduct.deleted_at.is_(None),
|
||||
CreditProduct.is_active.is_(True),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
if product is None:
|
||||
raise ValueError("积分商品不存在、已下架或已删除")
|
||||
product_type = product.product_type
|
||||
if product_type == CreditProductType.TEAM_SUBSCRIPTION.value:
|
||||
if not 2 <= int(quantity) <= 20:
|
||||
raise ValueError("客户端团队套餐单次购买数量必须在2到20之间")
|
||||
team, available, reason = await resolve_team_purchase_context(db, user=user)
|
||||
if not available:
|
||||
raise ValueError(reason or "当前不能购买团队订阅套餐")
|
||||
if team is not None:
|
||||
await acquire_team_business_lock(db, team.id)
|
||||
team, available, reason = await resolve_team_purchase_context(db, user=user)
|
||||
if not available or team is None:
|
||||
raise ValueError(reason or "当前不能购买团队订阅套餐")
|
||||
team_id_snapshot = team.id if team else None
|
||||
first_purchase = bool(team is None or team.first_subscription_paid_at is None)
|
||||
else:
|
||||
if int(quantity) != 1:
|
||||
raise ValueError("个人订阅和积分增值包购买数量固定为1")
|
||||
quantity = 1
|
||||
first_purchase = user.first_membership_paid_at is None if product_type == CreditProductType.SUBSCRIPTION.value else False
|
||||
|
||||
if product_type in {CreditProductType.SUBSCRIPTION.value, CreditProductType.TEAM_SUBSCRIPTION.value}:
|
||||
ensure_repeat_purchase_allowed(product, first_purchase=first_purchase)
|
||||
incomplete_result = await db.execute(
|
||||
select(PaymentOrder.id).where(
|
||||
PaymentOrder.user_id == user_id,
|
||||
PaymentOrder.product_type.in_([
|
||||
CreditProductType.SUBSCRIPTION.value,
|
||||
CreditProductType.TEAM_SUBSCRIPTION.value,
|
||||
]),
|
||||
(
|
||||
(PaymentOrder.status == "pending")
|
||||
| ((PaymentOrder.status == "paid") & (PaymentOrder.fulfillment_status != "fulfilled"))
|
||||
),
|
||||
).limit(1)
|
||||
)
|
||||
if incomplete_result.scalar_one_or_none():
|
||||
raise ValueError("存在未完成的订阅订单,请先前往订单记录完成支付或处理原订单")
|
||||
|
||||
quote = quote_product(
|
||||
product,
|
||||
first_purchase=first_purchase,
|
||||
request_time=checked_at,
|
||||
quantity=int(quantity),
|
||||
)
|
||||
quoted_unit = quote.quoted_unit_price
|
||||
quoted_amount = quote.quoted_amount
|
||||
final_price = quoted_amount
|
||||
actual_unit = quoted_unit
|
||||
purchase_scene = quote.purchase_scene
|
||||
price_type = quote.price_type
|
||||
label = product.name
|
||||
if product_type == CreditProductType.TEAM_SUBSCRIPTION.value:
|
||||
total_credits = to_credit_decimal((product.monthly_grant_credits or 0) * int(quantity))
|
||||
elif product_type == CreditProductType.SUBSCRIPTION.value:
|
||||
total_credits = to_credit_decimal(product.monthly_grant_credits or 0)
|
||||
else:
|
||||
total_credits = to_credit_decimal(product.grant_credits or 0)
|
||||
snapshot = {
|
||||
"id": product.id,
|
||||
"product_code": product.product_code,
|
||||
"product_type": product.product_type,
|
||||
"name": product.name,
|
||||
"description": product.description,
|
||||
"features": product.features_json or [],
|
||||
"tier_code": product.tier_code,
|
||||
"tier_rank": product.tier_rank,
|
||||
"billing_cycle": product.billing_cycle,
|
||||
"monthly_grant_credits": float(product.monthly_grant_credits or 0),
|
||||
"first_purchase_price": float(product.first_purchase_price or 0),
|
||||
"regular_price": float(product.regular_price or 0),
|
||||
"activity_price": float(product.activity_price) if product.activity_price is not None else None,
|
||||
"activity_start_at": product.activity_start_at.isoformat() if product.activity_start_at else None,
|
||||
"activity_end_at": product.activity_end_at.isoformat() if product.activity_end_at else None,
|
||||
"renewal_enabled": bool(product.renewal_enabled),
|
||||
"grant_credits": float(product.grant_credits or 0),
|
||||
"validity_months": product.validity_months,
|
||||
"credit_level": product.credit_level,
|
||||
"currency": product.currency,
|
||||
}
|
||||
|
||||
# 固定锁序:user advisory 已获取;团队订单已先获取 Team advisory,最后才锁 User 行。
|
||||
locked_user_result = await db.execute(select(User).where(User.id == user_id).limit(1).with_for_update())
|
||||
locked_user = locked_user_result.scalar_one_or_none()
|
||||
if locked_user is None:
|
||||
raise ValueError("用户不存在")
|
||||
if locked_user.team_id != user.team_id:
|
||||
raise ValueError("用户团队关系已发生变化,请刷新后重试")
|
||||
user = locked_user
|
||||
|
||||
# 先持久化订单主记录,再做升级周期预留。订阅周期的 upgrade_order_id
|
||||
# 有外键约束,若先更新周期后插入订单,flush 顺序可能触发外键异常。
|
||||
order = PaymentOrder(
|
||||
id=order_id,
|
||||
user_id=user_id,
|
||||
order_no=order_no,
|
||||
amount=price,
|
||||
credits=round(float(credits or 0) + float(bonus_credits or 0), 2),
|
||||
amount=final_price,
|
||||
credits=total_credits,
|
||||
payment_method=method,
|
||||
order_source=PaymentOrderSourceEnum.ONLINE_PAYMENT.value,
|
||||
status="pending",
|
||||
purchase_scene="legacy_recharge",
|
||||
price_type="regular",
|
||||
product_id=product.id if product else None,
|
||||
product_type=product_type,
|
||||
purchase_scene=purchase_scene,
|
||||
price_type=price_type,
|
||||
product_code_snapshot=product.product_code if product else None,
|
||||
product_name_snapshot=label,
|
||||
target_price_snapshot=price,
|
||||
deduction_amount_snapshot=0,
|
||||
payable_amount_snapshot=price,
|
||||
product_snapshot_json=snapshot,
|
||||
quantity=int(quantity),
|
||||
quoted_unit_price_snapshot=quoted_unit,
|
||||
quoted_amount_snapshot=quoted_amount,
|
||||
actual_unit_price_snapshot=actual_unit,
|
||||
team_id_snapshot=team_id_snapshot,
|
||||
fulfillment_status="pending" if product else None,
|
||||
)
|
||||
db.add(order)
|
||||
await db.flush()
|
||||
|
||||
if product_id:
|
||||
product_result = await db.execute(
|
||||
select(CreditProduct).where(CreditProduct.id == product_id, CreditProduct.is_active.is_(True)).limit(1)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
if product is None:
|
||||
raise ValueError("积分商品不存在或已下架")
|
||||
# 订阅报价服务内部先获取用户级 advisory lock,再按统一顺序锁订阅周期。
|
||||
# 此处不预先锁 users 行,避免与支付履约(advisory -> users)形成反向锁序。
|
||||
user_result = await db.execute(select(User).where(User.id == user_id).limit(1))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise ValueError("用户不存在")
|
||||
quote = await quote_and_reserve_product_purchase(
|
||||
db, user=user, product=product, order_id=order.id, request_time=checked_at
|
||||
)
|
||||
price = float(quote.payable_amount)
|
||||
label = product.name
|
||||
credits = float(product.grant_credits or product.monthly_grant_credits or 0)
|
||||
bonus_credits = 0.0
|
||||
order.amount = quote.payable_amount
|
||||
order.credits = round(float(credits or 0), 2)
|
||||
order.product_id = product.id
|
||||
order.product_type = product.product_type
|
||||
order.purchase_scene = quote.purchase_scene
|
||||
order.price_type = quote.price_type
|
||||
order.product_code_snapshot = product.product_code
|
||||
order.product_name_snapshot = product.name
|
||||
product_snapshot = product_to_dict(product)
|
||||
for time_key in ("activity_start_at", "activity_end_at"):
|
||||
value = product_snapshot.get(time_key)
|
||||
if value is not None:
|
||||
product_snapshot[time_key] = value.isoformat()
|
||||
order.product_snapshot_json = product_snapshot
|
||||
order.source_subscription_id = quote.source_subscription_id
|
||||
order.upgrade_period_ids_json = list(quote.upgrade_period_ids) or None
|
||||
order.target_price_snapshot = quote.target_price
|
||||
order.deduction_amount_snapshot = quote.deduction_amount
|
||||
order.payable_amount_snapshot = quote.payable_amount
|
||||
order.fulfillment_status = "pending"
|
||||
|
||||
total_credits = round(float(credits or 0) + float(bonus_credits or 0), 2)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"ORDER_CREATED order_no={order.order_no} user={user_id} amount={price} "
|
||||
f"credits={total_credits} method={method} product_id={product_id} mock={mock_mode}"
|
||||
f"ORDER_CREATED order_no={order.order_no} user={user_id} amount={order.amount} "
|
||||
f"credits={order.credits} method={method} product_id={product_id} quantity={quantity} mock={mock_mode}"
|
||||
)
|
||||
|
||||
if mock_mode:
|
||||
order.status = "paid"
|
||||
order.paid_at = checked_at
|
||||
desc = f"充值{label}({total_credits}积分)"
|
||||
if bonus_credits > 0:
|
||||
desc += f"(含赠送{bonus_credits}积分)"
|
||||
await _fulfill_paid_order(
|
||||
db,
|
||||
order=order,
|
||||
fulfilled_at=checked_at,
|
||||
legacy_description=desc,
|
||||
)
|
||||
desc = f"充值{label}({order.credits}积分)"
|
||||
await _fulfill_paid_order(db, order=order, fulfilled_at=checked_at, legacy_description=desc)
|
||||
await db.flush()
|
||||
else:
|
||||
if method == "wechat":
|
||||
qr_code_content = _create_wechat_order(order, db_configs)
|
||||
if qr_code_content:
|
||||
order.qr_url = qr_code_content # type: ignore[attr-defined]
|
||||
else:
|
||||
if quote and quote.upgrade_period_ids:
|
||||
await release_upgrade_reservation(db, order=order, released_at=checked_at)
|
||||
raise ValueError("微信支付预下单失败,请检查配置或稍后重试")
|
||||
elif method == "alipay":
|
||||
qr_url = _create_alipay_order(order, db_configs)
|
||||
if qr_url:
|
||||
order.qr_url = qr_url # type: ignore[attr-defined]
|
||||
else:
|
||||
if quote and quote.upgrade_period_ids:
|
||||
await release_upgrade_reservation(db, order=order, released_at=checked_at)
|
||||
raise ValueError("支付宝预下单失败,请检查配置或稍后重试")
|
||||
elif method == "wechat":
|
||||
qr_code_content = _create_wechat_order(order, db_configs)
|
||||
if not qr_code_content:
|
||||
raise ValueError("微信支付预下单失败,请检查配置或稍后重试")
|
||||
order.qr_url = qr_code_content # type: ignore[attr-defined]
|
||||
elif method == "alipay":
|
||||
qr_url = _create_alipay_order(order, db_configs)
|
||||
if not qr_url:
|
||||
raise ValueError("支付宝预下单失败,请检查配置或稍后重试")
|
||||
order.qr_url = qr_url # type: ignore[attr-defined]
|
||||
return order
|
||||
|
||||
|
||||
@@ -1238,10 +1304,6 @@ async def sync_pending_orders(db: AsyncSession) -> int:
|
||||
updated_count += 1
|
||||
elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"):
|
||||
order.status = "cancelled"
|
||||
if order.upgrade_period_ids_json:
|
||||
await release_upgrade_reservation(
|
||||
db, order=order, released_at=utc_now()
|
||||
)
|
||||
await db.commit()
|
||||
updated_count += 1
|
||||
|
||||
@@ -1259,10 +1321,6 @@ async def sync_pending_orders(db: AsyncSession) -> int:
|
||||
updated_count += 1
|
||||
elif trade_state in ("CLOSED", "REVOKED"):
|
||||
order.status = "cancelled"
|
||||
if order.upgrade_period_ids_json:
|
||||
await release_upgrade_reservation(
|
||||
db, order=order, released_at=utc_now()
|
||||
)
|
||||
await db.commit()
|
||||
updated_count += 1
|
||||
except Exception as e:
|
||||
@@ -1596,86 +1654,18 @@ async def process_refund(
|
||||
db: AsyncSession,
|
||||
order_no: str,
|
||||
refund_amount: float | None = None,
|
||||
refund_reason: str = "管理员退款"
|
||||
refund_reason: str = "管理员退款",
|
||||
) -> dict:
|
||||
"""Process a refund for a paid order.
|
||||
|
||||
Args:
|
||||
db: async database session
|
||||
order_no: merchant order number
|
||||
refund_amount: amount to refund (defaults to full order amount)
|
||||
refund_reason: reason for refund
|
||||
|
||||
Returns:
|
||||
dict with refund result
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1)
|
||||
)
|
||||
"""本版本保留退款 Service 入口,但主动订单退款统一关闭。"""
|
||||
result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1))
|
||||
order = result.scalar_one_or_none()
|
||||
|
||||
if not order:
|
||||
return {"success": False, "message": "订单不存在"}
|
||||
|
||||
if order.status != "paid":
|
||||
return {"success": False, "message": f"订单状态为{order.status},无法退款"}
|
||||
|
||||
if order.refunded_at is not None:
|
||||
return {"success": False, "message": "订单已退款"}
|
||||
|
||||
refund_amount = to_credit_decimal(refund_amount if refund_amount is not None else order.amount)
|
||||
|
||||
# 金额校验
|
||||
if refund_amount > to_credit_decimal(order.amount):
|
||||
return {"success": False, "message": "退款金额超过订单金额"}
|
||||
|
||||
# 根据支付方式调用相应的退款API
|
||||
db_configs = await _get_payment_configs(db)
|
||||
if order.payment_method == "alipay":
|
||||
refund_result = await _refund_alipay_order(
|
||||
db, order, refund_amount, refund_reason, db_configs
|
||||
)
|
||||
if not refund_result.get("success"):
|
||||
return refund_result
|
||||
elif order.payment_method == "wechat":
|
||||
refund_result = await _refund_wechat_order(
|
||||
db, order, refund_amount, refund_reason, db_configs
|
||||
)
|
||||
if not refund_result.get("success"):
|
||||
return refund_result
|
||||
|
||||
# 按原支付业务位置适配新积分账本;不改变支付渠道退款流程。
|
||||
try:
|
||||
if order.product_id:
|
||||
await revoke_payment_order_credits(db, order=order, reason=refund_reason)
|
||||
else:
|
||||
await deduct_credits(
|
||||
db,
|
||||
order.user_id,
|
||||
order.credits,
|
||||
refund_reason,
|
||||
related_id=order.id,
|
||||
biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.REFUND.value, action=CreditRecordAction.REFUND.value),
|
||||
refund_for_biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value),
|
||||
record_meta=_payment_refund_meta(order),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to deduct credits for refund: {e}")
|
||||
return {"success": False, "message": "积分扣除失败"}
|
||||
|
||||
# 更新订单状态
|
||||
order.status = "refunded"
|
||||
order.refund_amount = refund_amount
|
||||
order.refunded_at = utc_now()
|
||||
if order.payment_method == "alipay":
|
||||
order.refund_trade_no = db_configs.get("refund_trade_no", "")
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"REFUND_SUCCESS order_no={order_no} user={order.user_id} "
|
||||
f"refund_amount={refund_amount}"
|
||||
logger.warning(
|
||||
"REFUND_BLOCKED order_no=%s user=%s requested_amount=%s reason=%s",
|
||||
order_no, order.user_id, refund_amount, refund_reason,
|
||||
)
|
||||
return {"success": True, "message": "退款成功"}
|
||||
return {"success": False, "message": "当前版本暂未开放订单退款"}
|
||||
|
||||
|
||||
async def _refund_alipay_order(
|
||||
|
||||
Reference in New Issue
Block a user