321 lines
17 KiB
Python
321 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""业务层 · 结算/合同/争议/撮合服务。
|
||
|
||
资金相关规则(佣金、结算、争议联动)在业务层集中,接口层瘦身。
|
||
|
||
结算(release_escrow)· 验收通过后:
|
||
1) 优先走「微信服务商分账」(资金托管):
|
||
接单者 active 收款绑定 + 订单 transaction_id + 服务商配置齐备
|
||
→ 分账 95% 给接单者 / 5% 佣金给平台服务商商户号 → 等分账回调确认后置 released
|
||
2) 降级(未接微信收单 / 无绑定 / 分账未配置):
|
||
仅台账置 released(channel=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, *,
|
||
transaction_id: str = "", payer_sub_mchid: str = "") -> dict:
|
||
"""验收通过后结算:优先微信服务商分账(资金冻结→分账→解冻剩余),不满足条件降级台账结算。
|
||
|
||
完整资金生命周期(微信服务商分账,出资方=接单者特约商户):
|
||
1. 冻结:订单带 profit_sharing=true 支付成功后资金冻结在出资子商户不可用余额;
|
||
业务侧 escrow 置 frozen + 记录 transaction_id / payer_sub_mchid;
|
||
2. 查询剩余待分金额(确认可分金额);
|
||
3. 分账:平台佣金 5% 分给服务商商户号(部分分账 unfreeze_unsplit=False);
|
||
4. 查询分账结果(state=FINISHED 且 receivers.result=SUCCESS);
|
||
5. 解冻剩余:调分账完结接口把剩余 95% 解冻回出资方(接单者)账户 → escrow released。
|
||
"""
|
||
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)
|
||
|
||
# 冻结确认:外部传入微信交易单号 + 出资商户号 → 记录并置 frozen
|
||
if transaction_id:
|
||
esc = await self.db.escrows.update_fields(esc["id"], {
|
||
"channel": "wx_split", "transaction_id": transaction_id,
|
||
"payer_sub_mchid": payer_sub_mchid,
|
||
"share_status": "pending",
|
||
})
|
||
if esc["status"] in ("deposited", "released"):
|
||
esc = await self.db.escrows.set_status(esc["id"], "frozen")
|
||
await self._log(esc, "wx_split", "托管资金已冻结(微信支付分账冻结)", "initiated",
|
||
f"tx={transaction_id} payer={payer_sub_mchid}")
|
||
logger.info("托管资金冻结确认 task=%s tx=%s payer=%s", task_id, transaction_id, payer_sub_mchid)
|
||
|
||
tx_id = esc.get("transaction_id") or ""
|
||
if not tx_id or not pay_config.profitsharing_enabled():
|
||
# 降级:未接微信收单 / 服务商分账未配置 → 台账结算
|
||
return await self._manual_release(esc, "未接入微信收单或分账未配置,台账结算",
|
||
f"no_transaction_id={not tx_id}")
|
||
|
||
receiver, reason = await self._resolve_receiver(task)
|
||
if receiver is None:
|
||
return await self._manual_release(esc, "接单者无收款绑定,台账结算", reason)
|
||
if not (receiver.get("sub_mchid") or ""):
|
||
return await self._manual_release(esc, "接单者绑定无出资商户号(sub_mchid),台账结算",
|
||
f"binding={receiver.get('id')}")
|
||
|
||
return await self._split_lifecycle(esc, receiver)
|
||
|
||
async def _manual_release(self, esc: dict, note: str, detail: str = "") -> dict:
|
||
"""降级台账结算(不碰真金,供对账补齐)。"""
|
||
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", note, "shared", detail)
|
||
return esc
|
||
|
||
async def _split_lifecycle(self, esc: dict, receiver: dict) -> dict:
|
||
"""微信服务商分账完整生命周期:剩余待分确认 → 分账 → 结果确认 → 解冻剩余。"""
|
||
sub_mchid = receiver.get("sub_mchid") or ""
|
||
tx_id = esc.get("transaction_id") or ""
|
||
if not tx_id or not sub_mchid:
|
||
return await self._manual_release(esc, "分账前置缺失(transaction_id/sub_mchid)",
|
||
f"tx={tx_id} sub_mchid={sub_mchid}")
|
||
|
||
# ── 1) 查询剩余待分金额(确认冻结资金)──
|
||
remaining = await profitsharing.query_remaining_amount(transaction_id=tx_id)
|
||
if remaining is None:
|
||
return await self._manual_release(esc, "查询剩余待分金额失败,台账结算", "amount_query_failed")
|
||
unsplit_fen = int(remaining.get("unsplit_amount") or 0)
|
||
total_fen = int(esc["amount"]) * 100
|
||
if unsplit_fen <= 0:
|
||
return await self._manual_release(esc, "订单无可分金额(可能已分账/已解冻)",
|
||
f"unsplit={unsplit_fen}")
|
||
# 以微信侧实际可分金额为准(防超分),但不超过台账总额
|
||
usable_fen = min(unsplit_fen, total_fen)
|
||
commission_fen = min(int(esc["commission"]) * 100, usable_fen)
|
||
opc_remain_fen = usable_fen - commission_fen
|
||
|
||
# ── 2) 发起分账:平台佣金 5% 分给服务商商户号(部分分账,剩余保留待解冻)──
|
||
share_no = f"PS_{int(time.time())}_{secrets.token_hex(4).upper()}"
|
||
split_receivers = [
|
||
{
|
||
"type": "MERCHANT_ID",
|
||
"account": pay_config.WECHATPAY_SP_MCHID,
|
||
"amount": max(commission_fen, 0),
|
||
"description": "平台服务费(分账)",
|
||
},
|
||
]
|
||
try:
|
||
await profitsharing.create_split(
|
||
transaction_id=tx_id, out_order_no=share_no,
|
||
receivers=split_receivers, sub_mchid=sub_mchid,
|
||
unfreeze_unsplit=False,
|
||
)
|
||
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"],
|
||
"split_detail": json.dumps({"step": "split_request", "error": str(exc)[:300]},
|
||
ensure_ascii=False),
|
||
})
|
||
await self._log(esc, "wx_split", "分账发起失败", "failed", str(exc)[:300],
|
||
split_receivers)
|
||
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"],
|
||
"split_detail": json.dumps({
|
||
"step": "split_initiated", "unsplit_fen": unsplit_fen,
|
||
"commission_fen": commission_fen, "opc_remain_fen": opc_remain_fen,
|
||
}, ensure_ascii=False),
|
||
})
|
||
await self._log(esc, "wx_split", "分账已发起(平台佣金5%),等待结果", "initiated",
|
||
f"share={share_no}", split_receivers)
|
||
|
||
# ── 3) 查询分账结果(微信异步:PROCESSING → FINISHED)──
|
||
split_result = await profitsharing.query_split(
|
||
transaction_id=tx_id, out_order_no=share_no, sub_mchid=sub_mchid)
|
||
ok, fail_reason = profitsharing.split_finished(split_result) if split_result else (False, "无分账结果")
|
||
if not ok:
|
||
# PROCESSING:等微信回调(handle_split_notify)或后续轮询;不置终态
|
||
logger.info("分账处理中/未确认 task=%s share=%s reason=%s",
|
||
esc["task_id"], share_no, fail_reason)
|
||
return esc
|
||
|
||
# ── 4) 分账成功 → 解冻剩余资金回出资方(接单者),完结分账 ──
|
||
unfreeze_no = f"UF_{int(time.time())}_{secrets.token_hex(4).upper()}"
|
||
try:
|
||
await profitsharing.unfreeze_remaining(
|
||
transaction_id=tx_id, out_order_no=unfreeze_no,
|
||
sub_mchid=sub_mchid, description="任务验收通过,解冻剩余资金",
|
||
)
|
||
except RuntimeError as exc:
|
||
esc = await self.db.escrows.update_fields(esc["id"], {
|
||
"share_status": "failed",
|
||
"split_detail": json.dumps({"step": "unfreeze", "error": str(exc)[:300]},
|
||
ensure_ascii=False),
|
||
})
|
||
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"], {
|
||
"share_status": "shared",
|
||
"split_detail": json.dumps({
|
||
"step": "completed", "share_no": share_no, "unfreeze_no": unfreeze_no,
|
||
"commission_fen": commission_fen, "opc_remain_fen": opc_remain_fen,
|
||
"wx_split_result": split_result.get("order_id", ""),
|
||
}, ensure_ascii=False),
|
||
})
|
||
esc = await self.db.escrows.set_status(esc["id"], "released")
|
||
await self._log(esc, "wx_split", "分账完成+解冻剩余,资金已结算", "shared",
|
||
f"share={share_no} unfreeze={unfreeze_no}", split_receivers)
|
||
logger.info("分账生命周期完成 task=%s share=%s unfreeze=%s",
|
||
esc["task_id"], share_no, unfreeze_no)
|
||
return esc
|
||
|
||
async def query_split_lifecycle(self, task_id: str) -> dict:
|
||
"""查询任务分账生命周期状态(供前端/轮询展示:冻结→分账→解冻)。"""
|
||
escrows = await self.db.escrows.list()
|
||
esc = next((e for e in escrows if e["task_id"] == task_id), None)
|
||
if esc is None:
|
||
return {"exists": False}
|
||
detail = {}
|
||
try:
|
||
detail = json.loads(esc.get("split_detail") or "{}")
|
||
except (ValueError, TypeError):
|
||
detail = {}
|
||
out = {"exists": True, "escrow_id": esc["id"], "task_id": task_id,
|
||
"amount": esc["amount"], "commission": esc["commission"],
|
||
"status": esc["status"], "channel": esc["channel"],
|
||
"transaction_id": esc.get("transaction_id", ""),
|
||
"payer_sub_mchid": esc.get("payer_sub_mchid", ""),
|
||
"share_order_no": esc.get("share_order_no", ""),
|
||
"share_status": esc.get("share_status", ""),
|
||
"split_detail": detail}
|
||
# 分账处理中时主动查询微信侧最新结果(供轮询收敛)
|
||
if esc["status"] == "frozen" and esc.get("share_order_no") and esc.get("transaction_id"):
|
||
wx = await profitsharing.query_split(
|
||
transaction_id=esc["transaction_id"],
|
||
out_order_no=esc["share_order_no"],
|
||
sub_mchid=esc.get("payer_sub_mchid", ""),
|
||
)
|
||
if wx:
|
||
ok, reason = profitsharing.split_finished(wx)
|
||
out["wx_split_state"] = wx.get("state", "")
|
||
out["wx_split_finished"] = ok
|
||
out["wx_split_reason"] = reason
|
||
out["wx_split_receivers"] = wx.get("receivers", [])
|
||
return out
|
||
|
||
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]}
|