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