Files
server-core/app/services/settlement_service.py
T

321 lines
17 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, *,
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]}