Files
server-core/app/pay/routers.py
T

398 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""支付子应用接口层:微信回调 + 算力充值(C 端自服务)。
- ``POST /opc/pay/notify`` 微信支付回调(公网、无登录鉴权,靠 V3 验签)。
- ``/opc/compute/recharge/*`` 用户充值(套餐/下单/状态/记录),require_roles("opc_member")。
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile
from pydantic import BaseModel, Field
from ..api.dependencies import get_current_user, get_db
from ..infrastructure.repositories import Database
from ..rbac import require_roles
from . import profitsharing, service, wxpay
from .repository import ComputeRechargeOrderRepository
logger = logging.getLogger("pay.api")
router = APIRouter(tags=["pay"])
def _repo(db: Database) -> ComputeRechargeOrderRepository:
"""订单仓储(复用请求级 session;支付域自持仓储,便于整域拆分微服务)。"""
return ComputeRechargeOrderRepository(db.session)
# ── 微信支付回调(微信服务器调用,验签解密后到账)─────────────────────────────
@router.post("/opc/pay/notify", summary="微信支付回调(无登录鉴权,V3 验签)")
async def pay_notify(request: Request, db: Database = Depends(get_db)):
body = await request.body()
result = await wxpay.verify_and_decrypt(request.headers, body)
if not result or not isinstance(result, dict):
raise HTTPException(status_code=400, detail="回调验签/解密失败")
# 展平 resource 到顶层(out_trade_no / transaction_id / trade_state ...
resource = result.pop("resource", {})
if isinstance(resource, dict):
result.update(resource)
order_no = result.get("out_trade_no", "")
logger.info("微信支付回调: order=%s trade_state=%s", order_no, result.get("trade_state"))
try:
ok = await service.handle_notify(db, result)
except Exception as exc: # noqa: BLE001
logger.error("回调处理异常 order=%s: %s", order_no, exc, exc_info=True)
raise HTTPException(status_code=500, detail="处理失败,等待微信重试") from exc
if not ok:
raise HTTPException(status_code=500, detail="处理失败,等待微信重试")
return {"code": "SUCCESS", "message": "成功"}
# ── 用户充值(C 端自服务)─────────────────────────────────────────────────────
class RechargeOrderRequest(BaseModel):
"""充值下单请求:package_id(套餐)或 amount_yuan(自由金额)二选一。"""
client_type: str = Field(default="native", description="native(桌面扫码) | jsapi(小程序)")
package_id: str = ""
amount_yuan: float | None = Field(default=None, ge=0)
@router.get("/opc/compute/recharge/packages", summary="充值套餐与支付开关")
async def recharge_packages(
db: Database = Depends(get_db),
_u: dict = Depends(require_roles("opc_member")),
):
return await service.packages(db)
@router.post("/opc/compute/recharge/orders", summary="创建充值订单(native=二维码 / jsapi=小程序支付参数)")
async def recharge_create(
body: RechargeOrderRequest,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
try:
order = await service.create_order(
db, user, client_type=body.client_type,
package_id=body.package_id, amount_yuan=body.amount_yuan,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return order
@router.get("/opc/compute/recharge/orders/{order_no}/pay-params", summary="小程序扫码确认支付(JSAPI 参数,支持代付)")
async def recharge_pay_params(
order_no: str,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""微信扫桌面端小程序码 → 打开小程序本接口取 wx.requestPayment 参数。
支持代付:任意登录用户均可为该订单支付,余额到账到订单创建者。"""
try:
return await service.build_pay_params(db, user, order_no)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@router.post("/opc/compute/recharge/orders/{order_no}/status", summary="查询充值订单状态(含主动对账补单,支持代付查询)")
async def recharge_status(
order_no: str,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
repo = _repo(db)
order = await repo.get_by_order_no(order_no)
if order is None:
raise HTTPException(status_code=404, detail="订单不存在")
order = await service.reconcile(db, order)
return {
"order_no": order["order_no"], "status": order["status"],
"paid": order["status"] in ("paid", "credited"),
"credited": order["status"] == "credited",
"amount_fen": order["amount_fen"], "quota_micro": order["quota_micro"],
"bonus_quota_micro": order["bonus_quota_micro"],
"transaction_id": order["transaction_id"], "expires_at": order["expires_at"],
"paid_at": order.get("paid_at", ""), "credited_at": order.get("credited_at", ""),
}
@router.get("/opc/compute/recharge/orders", summary="我的充值记录")
async def recharge_orders(
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
return {"items": await _repo(db).list_by_user(user["id"])}
# ── 微信服务商分账回调(微信服务器调用,无登录鉴权,V3 验签)───────────────────
@router.post("/opc/pay/profitsharing/notify", summary="微信分账结果回调(无登录鉴权,V3 验签)")
async def profitsharing_notify(
request: Request,
db: Database = Depends(get_db),
):
body = await request.body()
result = await profitsharing.verify_split_notify(request.headers, body)
if not result or not isinstance(result, dict):
raise HTTPException(status_code=400, detail="回调验签/解密失败")
resource = result.pop("resource", {})
if isinstance(resource, dict):
result.update(resource)
logger.info("微信分账回调: out_order_no=%s", result.get("out_order_no"))
try:
ok = await service.handle_profitsharing_notify(db, result)
except Exception as exc: # noqa: BLE001
logger.error("分账回调处理异常: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail="处理失败,等待微信重试") from exc
if not ok:
raise HTTPException(status_code=500, detail="处理失败,等待微信重试")
return {"code": "SUCCESS", "message": "成功"}
# ── OPC 收款绑定(个人 openid / 商户子商户号 双路径)──────────────────────────
class BindPersonalRequest(BaseModel):
"""个人收款绑定:openid 取自登录用户小程序身份。"""
real_name: str = Field(default="", description="实名(用于微信校验,缺省用昵称)")
class BindMerchantRequest(BaseModel):
"""商户收款绑定:进件申请单号 或 已回填的子商户号。"""
sub_mchid: str = Field(default="", description="特约商户子商户号(进件通过后回填)")
applyment_id: str = Field(default="", description="进件申请单号(进件中)")
@router.post("/opc/pay/bindings/personal", summary="个人收款绑定(分账到微信零钱)")
async def opc_bind_personal(
body: BindPersonalRequest,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
try:
return await service.bind_personal(db, user, real_name=body.real_name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@router.post("/opc/pay/bindings/merchant", summary="商户收款绑定(进件 / 子商户号)")
async def opc_bind_merchant(
body: BindMerchantRequest,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
try:
return await service.bind_merchant(
db, user, sub_mchid=body.sub_mchid, applyment_id=body.applyment_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@router.get("/opc/pay/bindings", summary="我的收款绑定列表")
async def opc_bindings(
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
return {"items": await service.list_bindings(db, user)}
@router.post("/opc/pay/bindings/{binding_id}/disable", summary="停用收款绑定")
async def opc_disable_binding(
binding_id: str,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
try:
return await service.disable_binding(db, user, binding_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
# ── 特约商户进件(平台以服务商身份代提交,OPC 扫码签约激活)────────────────────
class ContactInfoReq(BaseModel):
"""进件超级管理员信息(敏感字段微信侧加密)。"""
contact_name: str = Field(default="", description="超级管理员姓名")
contact_id_number: str = Field(default="", description="超级管理员身份证号")
mobile_phone: str = Field(default="", description="超级管理员手机号")
contact_email: str = Field(default="", description="超级管理员邮箱")
openid: str = Field(default="", description="超级管理员 openid(缺省用小程序身份)")
class BusinessLicenseInfoReq(BaseModel):
license_copy: str = Field(default="", description="营业执照照片 media_id")
license_number: str = Field(default="", description="营业执照注册号")
merchant_name: str = Field(default="", description="商户名称")
legal_person: str = Field(default="", description="法人姓名")
class IdCardInfoReq(BaseModel):
id_card_copy: str = Field(default="", description="法人身份证照片 media_id")
id_card_name: str = Field(default="", description="法人姓名")
id_card_number: str = Field(default="", description="法人身份证号")
id_card_period_begin: str = Field(default="", description="证件开始日期")
id_card_period_end: str = Field(default="", description="证件结束日期(长期填 长期)")
class IdentityInfoReq(BaseModel):
id_doc_type: str = Field(default="IDENTIFICATION_TYPE_IDCARD", description="证件类型")
id_card_info: IdCardInfoReq | None = None
class SubjectInfoReq(BaseModel):
subject_type: str = Field(default="SUBJECT_TYPE_ENTERPRISE", description="主体类型(企业/个体户/其他)")
business_license_info: BusinessLicenseInfoReq | None = None
identity_info: IdentityInfoReq | None = None
class SalesInfoReq(BaseModel):
sales_scenes_type: list[str] = Field(default_factory=lambda: ["SALES_SCENES_MP"])
class BusinessInfoReq(BaseModel):
merchant_shortname: str = Field(default="", description="商户简称")
service_phone: str = Field(default="", description="客服电话")
sales_info: SalesInfoReq | None = None
class SettlementInfoReq(BaseModel):
settlement_id: str = Field(default="719", description="结算规则 ID719=餐饮等按行业)")
qualification_type: str = Field(default="", description="结算规则资质类型")
class BankAccountInfoReq(BaseModel):
bank_account_type: str = Field(default="BANK_ACCOUNT_TYPE_CORPORATE", description="账户类型(对公/个人)")
account_name: str = Field(default="", description="开户名称")
account_bank: str = Field(default="", description="开户银行")
bank_address_code: str = Field(default="", description="开户银行省市编码")
account_number: str = Field(default="", description="银行账号")
class ApplymentRequest(BaseModel):
"""特约商户进件申请资料(对应微信 applyment4sub 接口)。"""
contact_info: ContactInfoReq | None = None
subject_info: SubjectInfoReq | None = None
business_info: BusinessInfoReq | None = None
settlement_info: SettlementInfoReq | None = None
bank_account_info: BankAccountInfoReq | None = None
addition_info: dict | None = None
@router.post("/opc/pay/bindings/merchant/upload-media", summary="上传进件素材(营业执照/证件照等)→ media_id")
async def opc_upload_applyment_media(
file: UploadFile = File(...),
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
"""上传图片素材(≤10MB,微信进件用),返回 media_id(有效期 3 天,需及时用于进件)。"""
tmp_path = ""
try:
data = await file.read()
if len(data) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="素材文件超过 10MB 限制")
ext = os.path.splitext(file.filename or "upload.jpg")[-1].lower() or ".jpg"
fd, tmp_path = tempfile.mkstemp(suffix=ext)
with os.fdopen(fd, "wb") as f:
f.write(data)
media = await profitsharing.upload_image(filepath=tmp_path, filename=file.filename or "")
return {"media_id": media.get("media_id", ""), "filename": file.filename or ""}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
finally:
if tmp_path and os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except OSError:
pass
@router.post("/opc/pay/bindings/merchant/applyment", summary="发起特约商户进件(平台代提交,微信审核 1–3 工作日)")
async def opc_create_applyment(
body: ApplymentRequest,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
try:
return await service.create_merchant_applyment(
db, user,
contact_info=body.contact_info.model_dump() if body.contact_info else {},
subject_info=body.subject_info.model_dump() if body.subject_info else {},
business_info=body.business_info.model_dump() if body.business_info else {},
settlement_info=body.settlement_info.model_dump() if body.settlement_info else {},
bank_account_info=body.bank_account_info.model_dump() if body.bank_account_info else {},
addition_info=body.addition_info,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@router.get("/opc/pay/bindings/merchant/{binding_id}/applyment", summary="查询进件状态(通过后自动回填子商户号并激活)")
async def opc_query_applyment(
binding_id: str,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
try:
return await service.query_merchant_applyment(db, user, binding_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@router.get("/opc/pay/bindings/merchant/{binding_id}/split-config", summary="查询子商户分账授权/最大比例")
async def opc_split_config(
binding_id: str,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
"""查询微信侧子商户允许服务商分账的最大比例(需子商户在商户平台开通分账并授权)。"""
try:
return await service.query_split_permission(db, user, binding_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
class PartnerOrderRequest(BaseModel):
"""服务商代子商户收单(桌面端 OPC 开户认证后,任务托管款/企业付款下单)。"""
sub_mchid: str = Field(description="出资特约商户号(接单者 OPC 的特约商户号)")
out_trade_no: str = Field(description="平台业务订单号(幂等键)")
amount_fen: int = Field(gt=0, description="金额(分)")
description: str = Field(default="云超服任务托管款", description="订单描述")
client_type: str = Field(default="native", description="native(桌面扫码) | jsapi(小程序)")
payer_openid: str = Field(default="", description="jsapi 时:用户 openid(服务商 AppID 下)")
@router.post("/opc/pay/bindings/merchant/partner-order", summary="服务商代子商户收单(profit_sharing 冻结)")
async def opc_partner_order(
body: PartnerOrderRequest,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
"""服务商模式下单到接单者特约商户(资金进子商户不可用余额冻结),
返回 Native code_url 或 JSAPI pay_params。支付成功回调(/opc/pay/notify 或分账回调)后走分账。"""
try:
return await profitsharing.create_partner_order(
client_type=body.client_type, out_trade_no=body.out_trade_no,
total_fen=body.amount_fen, description=body.description,
sub_mchid=body.sub_mchid, payer_sp_openid=body.payer_openid,
profit_sharing=True,
)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc