Files
server-core/scripts/smoke_profitsharing.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

110 lines
5.0 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 -*-
"""微信服务商分账改造 · 冒烟测试(临时 sqlite,不碰主库)。
覆盖:
1. release_escrow:未接微信收单/分账未配置 → 降级台账结算(channel=manual, released
2. release_escrow:配置齐备 + 接单者 active 绑定 → 发起微信分账(mock create_split
3. handle_split_notify:回调确认 → escrow released + log shared(幂等)
"""
from __future__ import annotations
import asyncio
import os
import sys
import tempfile
from pathlib import Path
# 临时库:优先 sqlite 内存/临时文件,避免触碰 .env 指定的 MySQL 主库
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_tmp.close()
os.environ["PINEAGENTS_DEMO_DATABASE_URL"] = f"sqlite+aiosqlite:///{_tmp.name}"
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT))
from unittest.mock import patch # noqa: E402
from app.infrastructure.db import Base # noqa: E402
from app.infrastructure.repositories import Database # noqa: E402
import app.pay.models # noqa: E402,F401 确保新模型注册
import app.infrastructure.models # noqa: E402,F401
from app.services.settlement_service import SettlementService # noqa: E402
async def _setup(db: Database):
async with db._engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def _mk_completed_task(db, claimer: str = "") -> dict:
return await db.tasks.create({
"task_code": "smk001", "title": "冒烟任务", "status": "completed",
"budget_min": 1000, "budget_max": 1000, "claimed_by": claimer or None,
})
async def main() -> None:
db = Database()
await _setup(db)
# ── 1) 未配置分账 / 无收单 → 降级台账结算 ────────────────────────────────
task = await _mk_completed_task(db)
svc = SettlementService(db)
r = await svc.release_escrow(task["id"])
print("① 降级台账:", r["status"], r["channel"], r["share_status"])
assert r["status"] == "released" and r["channel"] == "manual"
# 直接查库验证流水
import sqlite3
conn = sqlite3.connect(_tmp.name)
row = conn.execute("SELECT channel,status,detail FROM settlement_logs ORDER BY created_at DESC LIMIT 1").fetchone()
conn.close()
print("① 流水:", row)
assert row and row[0] == "manual" and row[1] == "shared"
# ── 2) 配置齐备 + 接单者 active 绑定 → 发起分账 ─────────────────────────
from app.pay.repository import PaymentBindingRepository
# 造一个接单者用户 + 商户绑定
user = await db.users.create("smk_opc", "pw123456", nickname="接单者", role="opc_member")
bind = await PaymentBindingRepository(db.session).create({
"user_id": user["id"], "bind_type": "merchant",
"sub_mchid": "1900000109", "status": "active",
})
task2 = await _mk_completed_task(db)
await db.tasks.claim(task2["id"], user["id"]) # 接单者=用户
await db.tasks.set_status(task2["id"], "completed") # 接单后置完成(验收通过)
esc = await db.escrows.create(task2["id"], task2["title"], 1000, 50)
await db.escrows.update_fields(esc["id"], {"transaction_id": "4200001234"})
with patch.object(
__import__("app.pay.config", fromlist=["profitsharing_enabled"]),
"profitsharing_enabled", return_value=True,
), patch("app.pay.profitsharing.create_split") as mock_split:
r2 = await svc.release_escrow(task2["id"])
mock_split.assert_called_once()
call = mock_split.call_args.kwargs
print("② 分账发起: channel=%s share_status=%s" % (r2["channel"], r2["share_status"]))
print("② 接收方:", [(x["type"], x["amount"]) for x in call["receivers"]])
assert r2["channel"] == "wx_split" and r2["share_status"] == "sharing"
assert call["receivers"][0]["amount"] == 95000 and call["receivers"][1]["amount"] == 5000
assert call["receivers"][0]["type"] == "MERCHANT_ID" # 商户绑定 → MERCHANT_ID
share_no = call["out_order_no"]
# ── 3) 回调确认(幂等)───────────────────────────────────────────────────
ok1 = await svc.handle_split_notify({"out_order_no": share_no})
conn = sqlite3.connect(_tmp.name)
esc_state = conn.execute("SELECT status,share_status FROM escrows WHERE id=?", (esc["id"],)).fetchone()
log_state = conn.execute("SELECT status FROM settlement_logs WHERE share_order_no=?", (share_no,)).fetchone()
conn.close()
print("③ 回调后 escrow:", esc_state, "log:", log_state)
assert ok1 and esc_state == ("released", "shared") and log_state == ("shared",)
ok2 = await svc.handle_split_notify({"out_order_no": share_no}) # 重放
assert ok2 # 幂等不报错
print("③ 幂等重放 OK")
print("\n✅ 冒烟测试全部通过")
await db.close()
os.unlink(_tmp.name)
if __name__ == "__main__":
asyncio.run(main())