398 lines
18 KiB
Python
398 lines
18 KiB
Python
# -*- 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="结算规则 ID(719=餐饮等按行业)")
|
||
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
|