Files
server-core/app/services/settlement_service.py
T
Pine 0fd210ac1f feat(pay): 微信服务商分账+资金托管改造——OPC个人/商户双绑定收款、验收95/5分账、回调确认、escrow资金托管字段扩展
- profitsharing.py:服务商分账客户端(添加接收方/发起分账/查分账/退款/回调验签)
- payment_bindings + settlement_logs:OPC 收款绑定(personal openid / merchant 子商户号)与分账流水镜像
- settlement_service.release_escrow:验收后优先发起微信分账(95%接单者/5%平台),未配置时降级台账结算(channel=manual)
- handle_split_notify:分账回调确认 escrow released(幂等)
- 路由:分账回调 + 绑定创建/列表/停用
- 迁移:alembic 0039(新表 + escrows 幂等加列)+ 兜底迁移脚本 + 冒烟测试
2026-09-01 18:39:41 +08:00

199 lines
9.7 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 -*-
"""业务层 · 结算/合同/争议/撮合服务。
资金相关规则(佣金、结算、争议联动)在业务层集中,接口层瘦身。
结算(release_escrow)· 验收通过后:
1) 优先走「微信服务商分账」(资金托管):
接单者 active 收款绑定 + 订单 transaction_id + 服务商配置齐备
→ 分账 95% 给接单者 / 5% 佣金给平台服务商商户号 → 等分账回调确认后置 released
2) 降级(未接微信收单 / 无绑定 / 分账未配置):
仅台账置 releasedchannel=manual),不阻塞业务,供后续对账补齐。
"""
from __future__ import annotations
import json
import logging
import secrets
import time
from fastapi import HTTPException
from ..infrastructure.repositories import Database
from ..pay import config as pay_config
from ..pay import profitsharing
from ..pay.repository import PaymentBindingRepository, SettlementLogRepository
# 平台佣金比例
COMMISSION_RATE = 0.05
logger = logging.getLogger("services.settlement")
class SettlementService:
"""结算托管、电子合同、争议、投资撮合。"""
def __init__(self, db: Database):
self.db = db
async def release_escrow(self, task_id: str) -> dict:
"""验收通过后结算:优先微信服务商分账,不满足条件时降级台账结算。"""
task = await self.db.tasks.get(task_id)
if task is None or task["status"] != "completed":
raise HTTPException(status_code=400, detail="任务未完成,不能结算")
escrows = await self.db.escrows.list()
esc = next((e for e in escrows if e["task_id"] == task_id), None)
if esc is None:
amount = max(task.get("budget_min", 0), task.get("budget_max", 0))
commission = int(amount * COMMISSION_RATE)
esc = await self.db.escrows.create(task_id, task["title"], amount, commission)
tx_id = esc.get("transaction_id") or ""
if not tx_id or not pay_config.profitsharing_enabled():
# 降级:未接微信收单 / 服务商分账未配置 → 台账结算
esc = await self.db.escrows.update_fields(
esc["id"], {"channel": "manual", "share_status": "manual"})
esc = await self.db.escrows.set_status(esc["id"], "released")
await self._log(esc, "manual", "未接入微信收单或分账未配置,台账结算", "shared",
f"no_transaction_id={not tx_id}")
return esc
receiver, reason = await self._resolve_receiver(task)
if receiver is None:
esc = await self.db.escrows.update_fields(
esc["id"], {"channel": "manual", "share_status": "manual"})
esc = await self.db.escrows.set_status(esc["id"], "released")
await self._log(esc, "manual", "接单者无收款绑定,台账结算", "shared", reason)
return esc
# 发起微信分账:金额(元→分);接单者 = 总额 - 佣金,平台佣金单独分给服务商商户号
total_fen = int(esc["amount"]) * 100
commission_fen = int(esc["commission"]) * 100
opc_fen = total_fen - commission_fen
share_no = f"PS_{int(time.time())}_{secrets.token_hex(4).upper()}"
receivers = [
{
"type": "MERCHANT_ID" if receiver["bind_type"] == "merchant" else "PERSONAL_OPENID",
"account": receiver.get("sub_mchid") or receiver.get("openid"),
"amount": max(opc_fen, 0),
"description": "任务服务费",
},
{
"type": "MERCHANT_ID",
"account": pay_config.WECHATPAY_SP_MCHID,
"amount": commission_fen,
"description": "平台服务费",
},
]
try:
await profitsharing.create_split(
transaction_id=tx_id, out_order_no=share_no, receivers=receivers,
sub_mchid=receiver.get("sub_mchid") or "",
)
except RuntimeError as exc:
esc = await self.db.escrows.update_fields(esc["id"], {
"channel": "wx_split", "share_order_no": share_no,
"share_status": "failed", "receiver_binding_id": receiver["id"],
})
await self._log(esc, "wx_split", "分账发起失败", "failed", str(exc)[:300])
raise HTTPException(status_code=502, detail=f"分账发起失败:{exc}") from exc
esc = await self.db.escrows.update_fields(esc["id"], {
"channel": "wx_split", "share_order_no": share_no,
"share_status": "sharing", "receiver_binding_id": receiver["id"],
})
await self._log(esc, "wx_split", "分账已发起,等待微信回调确认", "initiated",
receivers_json=receivers)
logger.info("分账已发起 task=%s share=%s receiver=%s", task_id, share_no, receiver["id"])
return esc
async def handle_split_notify(self, result: dict) -> bool:
"""分账结果回调:微信确认分账成功 → escrow released + 流水 shared(幂等)。"""
share_no = result.get("out_order_no", "")
if not share_no:
logger.warning("分账回调缺少 out_order_no")
return True
repo = SettlementLogRepository(self.db.session)
log = await repo.get_by_share_order_no(share_no)
if log is None:
logger.warning("分账回调无对应流水: %s", share_no)
return True
if log["status"] == "shared":
return True # 已确认(回调重放)
esc = await self.db.escrows.get(log["escrow_id"])
if esc is not None:
await self.db.escrows.update_fields(esc["id"], {"share_status": "shared"})
await self.db.escrows.set_status(esc["id"], "released")
await repo.set_status(log["id"], "shared", "微信分账回调确认")
await self.db.audit.add(action="escrow.released_wx", resource="escrow",
resource_id=log["escrow_id"],
detail=f"share={share_no}", user_id="")
logger.info("分账回调确认 share=%s escrow=%s", share_no, log["escrow_id"])
return True
async def _resolve_receiver(self, task: dict) -> tuple[dict | None, str]:
"""接单者(claimed_by)的可用收款绑定;无则返回 (None, 原因)。"""
claimer = task.get("claimed_by")
if not claimer:
return None, "任务无接单者(claimed_by 为空)"
repo = PaymentBindingRepository(self.db.session)
binding = await repo.get_active(claimer)
if binding is None:
return None, f"接单者无 active 收款绑定(user={claimer})"
return binding, ""
async def _log(self, esc: dict, channel: str, note: str, status: str,
detail: str = "", receivers_json: list | None = None) -> dict:
"""写分账流水镜像(金额统一为分,与微信口径一致)。"""
repo = SettlementLogRepository(self.db.session)
return await repo.create({
"escrow_id": esc.get("id", ""), "task_id": esc.get("task_id", ""),
"share_order_no": esc.get("share_order_no", ""), "channel": channel,
"amount": int(esc.get("amount", 0)) * 100,
"commission": int(esc.get("commission", 0)) * 100,
"receivers_json": json.dumps(receivers_json or [], ensure_ascii=False),
"status": status, "detail": detail or note,
})
async def sign_contract(self, task_id: str, actor: dict, opc_id: str | None = None) -> dict:
"""签订电子合同(幂等:已签返回现有合同)。"""
task = await self.db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
existing = await self.db.contracts.get_for_task(task_id)
if existing is not None:
return existing
resolved_opc = opc_id or (actor["id"] if actor.get("role") == "opc_member" else "")
return await self.db.contracts.create(task_id, task["title"], actor["id"], resolved_opc)
async def create_dispute(self, task_id: str, actor: dict, reason: str) -> dict:
"""发起争议并联动任务置为 disputed。"""
task = await self.db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
dispute = await self.db.disputes.create(task_id, task["title"], actor["id"], reason)
await self.db.tasks.set_status(task_id, "disputed")
return dispute
async def resolve_dispute(self, dispute_id: str) -> dict:
dispute = await self.db.disputes.set_status(dispute_id, "resolved", resolution="平台调解结案")
if dispute is None:
raise HTTPException(status_code=404, detail="Dispute not found")
return dispute
async def investor_matches(self, user_id: str) -> dict:
"""投资撮合:按投资人偏好(行业/阶段关键词)打分排序。"""
pref = await self.db.investor_prefs.get(user_id) or {}
industries = set(pref.get("industries") or [])
data = await self.db.portal_pages.get("investor", "projects") or {"items": []}
scored = []
for it in data.get("items", []):
text = f"{it.get('title', '')} {it.get('meta', '')}"
score = sum(10 for ind in industries if ind and ind in text)
if pref.get("stage") and pref["stage"] in text:
score += 5
scored.append((score, it))
scored.sort(key=lambda x: x[0], reverse=True)
return {"items": [{"title": it["title"], "meta": it["meta"], "tag": it.get("tag"),
"match": min(100, s + 50)} for s, it in scored]}