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 幂等加列)+ 兜底迁移脚本 + 冒烟测试
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""幂等迁移:为 ``escrows`` 表补充微信服务商分账扩展列(非运行态)。
|
||||
|
||||
用法:uv run python scripts/db/migrate_escrow_columns.py
|
||||
|
||||
- 通过 SQLAlchemy Inspector 检查缺失列,对缺失列执行 ``ALTER TABLE ... ADD COLUMN``;
|
||||
- 幂等:已存在列自动跳过,可重复执行;兼容 SQLite(默认)与 MySQL(生产)。
|
||||
- 新增表 ``payment_bindings`` / ``settlement_logs`` 由应用启动时 ``create_all`` 幂等创建,
|
||||
无需在此处理(本脚本只负责对既有表加列)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from sqlalchemy import create_engine, inspect # noqa: E402
|
||||
|
||||
from app.infrastructure.db import sync_database_url # noqa: E402
|
||||
|
||||
# escrows 需补充的扩展列(列名, 方言通用 DDL)
|
||||
ESCROW_COLUMNS = [
|
||||
("channel", "VARCHAR(32) NOT NULL DEFAULT 'wx_split'"),
|
||||
("transaction_id", "VARCHAR(64) NOT NULL DEFAULT ''"),
|
||||
("share_order_no", "VARCHAR(64) NOT NULL DEFAULT ''"),
|
||||
("share_status", "VARCHAR(32) NOT NULL DEFAULT 'pending'"),
|
||||
("receiver_binding_id", "VARCHAR(64) NOT NULL DEFAULT ''"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
url = sync_database_url()
|
||||
engine = create_engine(url)
|
||||
insp = inspect(engine)
|
||||
if "escrows" not in insp.get_table_names():
|
||||
print("escrows 表不存在(未建库),无需迁移")
|
||||
return
|
||||
existing = {c["name"] for c in insp.get_columns("escrows")}
|
||||
added, skipped = [], []
|
||||
for name, ddl in ESCROW_COLUMNS:
|
||||
if name in existing:
|
||||
skipped.append(name)
|
||||
continue
|
||||
with engine.begin() as conn:
|
||||
conn.exec_driver_sql(f"ALTER TABLE escrows ADD COLUMN {name} {ddl}")
|
||||
added.append(name)
|
||||
print(f"新增列: {added or '无'}")
|
||||
print(f"已存在跳过: {skipped or '无'}")
|
||||
engine.dispose()
|
||||
if added:
|
||||
print("迁移完成。")
|
||||
else:
|
||||
print("无新增列,escrows 已是最新。")
|
||||
print("注:新表 payment_bindings / settlement_logs 由应用启动时 create_all 幂等创建。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,109 @@
|
||||
# -*- 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())
|
||||
Reference in New Issue
Block a user