# -*- 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