功能: 增加鱼翅直充接口基础层
This commit is contained in:
@@ -70,6 +70,14 @@ YYB_WORKER_KEY=
|
|||||||
# YYB_WORKER_PORT=8810
|
# YYB_WORKER_PORT=8810
|
||||||
# DEV_YYB_WORKER_URL=http://127.0.0.1:8810
|
# DEV_YYB_WORKER_URL=http://127.0.0.1:8810
|
||||||
|
|
||||||
|
# 鱼翅直充供应商 API(供应商确认网关地址及订单查询字段后启用)
|
||||||
|
# AppKey 当前文档未说明其传输字段,保留以供后续确认,不会自动发送。
|
||||||
|
FISH_FIN_RECHARGE_BASE_URL=
|
||||||
|
FISH_FIN_RECHARGE_APP_ID=
|
||||||
|
FISH_FIN_RECHARGE_APP_KEY=
|
||||||
|
FISH_FIN_RECHARGE_APP_SECRET=
|
||||||
|
# FISH_FIN_RECHARGE_TIMEOUT=20
|
||||||
|
|
||||||
# Roundcube 邮件验证码读取服务地址(可选;不填则使用默认服务)
|
# Roundcube 邮件验证码读取服务地址(可选;不填则使用默认服务)
|
||||||
# MAIL_ROUNDCUBE_URL=http://127.0.0.1:8000/
|
# MAIL_ROUNDCUBE_URL=http://127.0.0.1:8000/
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from .login_api_wgapi import WgapiLoginAPI
|
|||||||
from .login_api_iframe import IframeLoginAPI
|
from .login_api_iframe import IframeLoginAPI
|
||||||
from .email_verifier import EmailVerifier
|
from .email_verifier import EmailVerifier
|
||||||
from .activity_client import DouyuActivityClient, DouyuActivityError
|
from .activity_client import DouyuActivityClient, DouyuActivityError
|
||||||
|
from .recharge_api import FishFinRechargeClient, FishFinRechargeConfig, FishFinRechargeError
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DouyuLogin",
|
"DouyuLogin",
|
||||||
@@ -16,4 +17,7 @@ __all__ = [
|
|||||||
"EmailVerifier",
|
"EmailVerifier",
|
||||||
"DouyuActivityClient",
|
"DouyuActivityClient",
|
||||||
"DouyuActivityError",
|
"DouyuActivityError",
|
||||||
|
"FishFinRechargeClient",
|
||||||
|
"FishFinRechargeConfig",
|
||||||
|
"FishFinRechargeError",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""鱼翅直充供应商 API 客户端。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class FishFinRechargeError(RuntimeError):
|
||||||
|
"""鱼翅直充供应商接口调用失败。"""
|
||||||
|
|
||||||
|
|
||||||
|
class FishFinRechargeConfigError(FishFinRechargeError):
|
||||||
|
"""鱼翅直充供应商配置不完整。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FishFinRechargeConfig:
|
||||||
|
"""供应商接入配置;敏感值仅从运行环境读取。"""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
app_id: str
|
||||||
|
app_secret: str
|
||||||
|
app_key: str = ""
|
||||||
|
timeout: tuple[float, float] = (8, 20)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "FishFinRechargeConfig":
|
||||||
|
"""从环境变量读取配置,不在代码或数据库中保存商户密钥。"""
|
||||||
|
timeout = float(os.getenv("FISH_FIN_RECHARGE_TIMEOUT", "20"))
|
||||||
|
return cls(
|
||||||
|
base_url=os.getenv("FISH_FIN_RECHARGE_BASE_URL", "").strip(),
|
||||||
|
app_id=os.getenv("FISH_FIN_RECHARGE_APP_ID", "").strip(),
|
||||||
|
app_secret=os.getenv("FISH_FIN_RECHARGE_APP_SECRET", "").strip(),
|
||||||
|
app_key=os.getenv("FISH_FIN_RECHARGE_APP_KEY", "").strip(),
|
||||||
|
timeout=(8, timeout),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
"""在实际发起请求前校验必要配置。"""
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, value in (
|
||||||
|
("FISH_FIN_RECHARGE_BASE_URL", self.base_url),
|
||||||
|
("FISH_FIN_RECHARGE_APP_ID", self.app_id),
|
||||||
|
("FISH_FIN_RECHARGE_APP_SECRET", self.app_secret),
|
||||||
|
)
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise FishFinRechargeConfigError(f"缺少鱼翅直充配置: {', '.join(missing)}")
|
||||||
|
|
||||||
|
|
||||||
|
class FishFinRechargeClient:
|
||||||
|
"""实现供应商 createOrderV2、queryOrderV2 与 userInfoV2 协议。"""
|
||||||
|
|
||||||
|
CREATE_ORDER_PATH = "/adapter-apiaccess/open/api/createOrderV2"
|
||||||
|
QUERY_ORDER_PATH = "/adapter-apiaccess/open/api/queryOrderV2"
|
||||||
|
USER_INFO_PATH = "/adapter-apiaccess/open/api/userInfoV2"
|
||||||
|
|
||||||
|
def __init__(self, config: FishFinRechargeConfig, *, session: requests.Session | None = None):
|
||||||
|
config.validate()
|
||||||
|
self.config = config
|
||||||
|
self.session = session or requests.Session()
|
||||||
|
self.session.trust_env = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sign_value(value: Any) -> str:
|
||||||
|
"""将参数转为待签名文本;对象按稳定紧凑 JSON 表示。"""
|
||||||
|
if isinstance(value, (dict, list, tuple)):
|
||||||
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def normalized_params(cls, params: Mapping[str, Any]) -> dict[str, str]:
|
||||||
|
"""按供应商规则去除首尾空白与空值,保留可签名参数。"""
|
||||||
|
normalized: dict[str, str] = {}
|
||||||
|
for raw_key, raw_value in params.items():
|
||||||
|
key = str(raw_key).strip()
|
||||||
|
if not key or key == "sign" or raw_value is None:
|
||||||
|
continue
|
||||||
|
value = cls._sign_value(raw_value).strip()
|
||||||
|
if value:
|
||||||
|
normalized[key] = value
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def sign(self, params: Mapping[str, Any], method: str) -> str:
|
||||||
|
"""生成小写 MD5 签名。"""
|
||||||
|
normalized = self.normalized_params(params)
|
||||||
|
query = "&".join(f"{key}={normalized[key]}" for key in sorted(normalized))
|
||||||
|
raw = f"{query}{method.upper()}{self.config.app_secret}"
|
||||||
|
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def verify_response_sign(self, payload: Mapping[str, Any], method: str) -> bool:
|
||||||
|
"""校验带 sign 的响应;供应商未返回 sign 时由调用方决定是否接受。"""
|
||||||
|
received = str(payload.get("sign") or "").strip().lower()
|
||||||
|
return bool(received) and received == self.sign(payload, method)
|
||||||
|
|
||||||
|
def _request(self, method: str, path: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
"""补齐公共参数、签名并执行一次 JSON 请求。"""
|
||||||
|
request_params: dict[str, Any] = {
|
||||||
|
"app_id": self.config.app_id,
|
||||||
|
"timestamp": int(time.time()),
|
||||||
|
**(params or {}),
|
||||||
|
}
|
||||||
|
request_params["sign"] = self.sign(request_params, method)
|
||||||
|
url = f"{self.config.base_url.rstrip('/')}{path}"
|
||||||
|
try:
|
||||||
|
if method.upper() == "GET":
|
||||||
|
response = self.session.get(url, params=request_params, timeout=self.config.timeout)
|
||||||
|
else:
|
||||||
|
response = self.session.post(url, json=request_params, timeout=self.config.timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise FishFinRechargeError(f"供应商请求失败: {exc}") from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise FishFinRechargeError("供应商响应不是 JSON") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise FishFinRechargeError("供应商响应格式无效")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _price(value: Decimal | int | float | str) -> str:
|
||||||
|
"""规范化金额,避免浮点数表达式进入签名或订单请求。"""
|
||||||
|
try:
|
||||||
|
price = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError) as exc:
|
||||||
|
raise ValueError("customer_price 必须是有效金额") from exc
|
||||||
|
if not price.is_finite() or price <= 0:
|
||||||
|
raise ValueError("customer_price 必须大于 0")
|
||||||
|
return format(price.normalize(), "f")
|
||||||
|
|
||||||
|
def create_order(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
charge_account: str,
|
||||||
|
buy_num: int,
|
||||||
|
customer_price: Decimal | int | float | str,
|
||||||
|
customer_order_no: str,
|
||||||
|
product_id: str,
|
||||||
|
recharge_arg: list[dict[str, Any]],
|
||||||
|
notify_url: str = "",
|
||||||
|
ext_arg: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""创建直充订单;成功后应使用 query_order 或供应商回调确认最终状态。"""
|
||||||
|
if not str(charge_account).strip():
|
||||||
|
raise ValueError("charge_account 不能为空")
|
||||||
|
if not str(customer_order_no).strip():
|
||||||
|
raise ValueError("customer_order_no 不能为空")
|
||||||
|
if not str(product_id).strip():
|
||||||
|
raise ValueError("product_id 不能为空")
|
||||||
|
if not isinstance(buy_num, int) or isinstance(buy_num, bool) or buy_num < 1:
|
||||||
|
raise ValueError("buy_num 必须是不小于 1 的整数")
|
||||||
|
if not isinstance(recharge_arg, list) or not recharge_arg:
|
||||||
|
raise ValueError("recharge_arg 必须是非空数组")
|
||||||
|
return self._request("POST", self.CREATE_ORDER_PATH, {
|
||||||
|
"charge_account": str(charge_account).strip(),
|
||||||
|
"buy_num": buy_num,
|
||||||
|
"customer_price": self._price(customer_price),
|
||||||
|
"customer_order_no": str(customer_order_no).strip(),
|
||||||
|
"product_id": str(product_id).strip(),
|
||||||
|
"recharge_arg": recharge_arg,
|
||||||
|
"notify_url": str(notify_url).strip(),
|
||||||
|
"ext_arg": ext_arg or {},
|
||||||
|
})
|
||||||
|
|
||||||
|
def query_order(self, **query: Any) -> dict[str, Any]:
|
||||||
|
"""查询订单。
|
||||||
|
|
||||||
|
文档未列出查询字段,调用方必须显式传入供应商确认的订单标识,例如
|
||||||
|
``customer_order_no`` 或供应商订单号。
|
||||||
|
"""
|
||||||
|
if not query or not any(str(value).strip() for value in query.values() if value is not None):
|
||||||
|
raise ValueError("查询订单至少需要一个非空订单标识")
|
||||||
|
return self._request("GET", self.QUERY_ORDER_PATH, query)
|
||||||
|
|
||||||
|
def account_info(self) -> dict[str, Any]:
|
||||||
|
"""查询商户账户信息。"""
|
||||||
|
return self._request("GET", self.USER_INFO_PATH)
|
||||||
@@ -40,6 +40,12 @@ services:
|
|||||||
# Worker 已合并到本容器,仅通过回环地址访问。
|
# Worker 已合并到本容器,仅通过回环地址访问。
|
||||||
- YYB_WORKER_URL=http://127.0.0.1:8810
|
- YYB_WORKER_URL=http://127.0.0.1:8810
|
||||||
- YYB_WORKER_KEY=${YYB_WORKER_KEY:-}
|
- YYB_WORKER_KEY=${YYB_WORKER_KEY:-}
|
||||||
|
# 鱼翅直充供应商凭据仅通过容器环境变量注入,不入库、不返回前端。
|
||||||
|
- FISH_FIN_RECHARGE_BASE_URL=${FISH_FIN_RECHARGE_BASE_URL:-}
|
||||||
|
- FISH_FIN_RECHARGE_APP_ID=${FISH_FIN_RECHARGE_APP_ID:-}
|
||||||
|
- FISH_FIN_RECHARGE_APP_KEY=${FISH_FIN_RECHARGE_APP_KEY:-}
|
||||||
|
- FISH_FIN_RECHARGE_APP_SECRET=${FISH_FIN_RECHARGE_APP_SECRET:-}
|
||||||
|
- FISH_FIN_RECHARGE_TIMEOUT=${FISH_FIN_RECHARGE_TIMEOUT:-20}
|
||||||
# Roundcube 邮件验证码读取服务地址(不填则使用代码默认值)
|
# Roundcube 邮件验证码读取服务地址(不填则使用代码默认值)
|
||||||
- MAIL_ROUNDCUBE_URL=${MAIL_ROUNDCUBE_URL:-}
|
- MAIL_ROUNDCUBE_URL=${MAIL_ROUNDCUBE_URL:-}
|
||||||
# 生产容器禁止 reload;开发环境由 dev.sh 单独启动 reload。
|
# 生产容器禁止 reload;开发环境由 dev.sh 单独启动 reload。
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 鱼翅直充接入分析
|
||||||
|
|
||||||
|
## 与现有扫码充值的关系
|
||||||
|
|
||||||
|
当前项目的 `create_gold_qr` 调用斗鱼 `https://cz.douyu.com/m/gold/getQrCode`,返回 `pay_url`,由用户微信扫码付款;到账通过斗鱼余额接口轮询确认。
|
||||||
|
|
||||||
|
本目录文档描述的是第三方供应商的直充订单协议:商户创建订单后,由供应商处理充值,调用方通过查询订单或异步通知得到最终状态。它不能替换为现有二维码支付接口,也不应复用现有的扫码支付轮询逻辑。
|
||||||
|
|
||||||
|
## 已完成的 API 基础层
|
||||||
|
|
||||||
|
`core/douyu/recharge_api.py` 提供:
|
||||||
|
|
||||||
|
- MD5 签名、响应签名校验辅助方法;
|
||||||
|
- 创建订单 `createOrderV2`;
|
||||||
|
- 查询订单 `queryOrderV2`;
|
||||||
|
- 商户账户查询 `userInfoV2`;
|
||||||
|
- 环境变量配置和请求、响应格式校验。
|
||||||
|
|
||||||
|
嵌套的 `recharge_arg`、`ext_arg` 在签名中使用稳定的紧凑 JSON 字符串。该序列化方式需要供应商联调确认;若其服务端使用其他规则,应在 `FishFinRechargeClient._sign_value` 中按确认规则调整。
|
||||||
|
|
||||||
|
## 接入前待供应商确认
|
||||||
|
|
||||||
|
1. API 网关基地址(文档只给出相对路径)。
|
||||||
|
2. `AppKey` 的实际传输位置与字段名。当前协议示例只签名并发送 `app_id`,客户端不会猜测发送 `AppKey`。
|
||||||
|
3. `queryOrderV2` 所需的订单标识字段名(商户单号、供应商单号或两者)。
|
||||||
|
4. 创建订单完整字段和 `recharge_arg` 模板,尤其是鱼翅商品 `product_id`、价格、数量含义,以及是否必须传 `ext_arg`。
|
||||||
|
5. `recharge_arg` / `ext_arg` 的嵌套签名序列化规则,以及请求与响应是否必须验签。
|
||||||
|
6. 回调地址、回调重试与回调验签规则;生产接入应持久化商户订单号并实现幂等处理。
|
||||||
|
|
||||||
|
## 运行时配置
|
||||||
|
|
||||||
|
在 `.env` 中设置以下变量,凭据不应写入源码、数据库或前端响应:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
FISH_FIN_RECHARGE_BASE_URL=https://supplier.example
|
||||||
|
FISH_FIN_RECHARGE_APP_ID=
|
||||||
|
FISH_FIN_RECHARGE_APP_KEY=
|
||||||
|
FISH_FIN_RECHARGE_APP_SECRET=
|
||||||
|
FISH_FIN_RECHARGE_TIMEOUT=20
|
||||||
|
```
|
||||||
|
|
||||||
|
确认上述信息后,下一步是在后台增加订单表、受权限保护的创建/查询接口、回调幂等处理,再将工作台的“充值鱼翅”从扫码路径显式分流为“扫码支付”和“供应商直充”两种方式。
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""鱼翅直充供应商 API 客户端测试。"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from core.douyu.recharge_api import (
|
||||||
|
FishFinRechargeClient,
|
||||||
|
FishFinRechargeConfig,
|
||||||
|
FishFinRechargeConfigError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FishFinRechargeClientTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.config = FishFinRechargeConfig(
|
||||||
|
base_url="https://supplier.example/",
|
||||||
|
app_id="15945681",
|
||||||
|
app_secret="ac9fa79db418fd2c61d9bd4c60956f2d5db3a6d781d6f91f2e96327c74dfaccd",
|
||||||
|
)
|
||||||
|
self.session = Mock()
|
||||||
|
self.client = FishFinRechargeClient(self.config, session=self.session)
|
||||||
|
|
||||||
|
def test_sign_matches_document_example(self):
|
||||||
|
sign = self.client.sign({
|
||||||
|
"app_id": "15945681 ",
|
||||||
|
"timestamp": 1740916504,
|
||||||
|
"charge_account": "18888888888",
|
||||||
|
"buy_num": 2,
|
||||||
|
"customer_price": 1.21,
|
||||||
|
"customer_order_no": "DD202507010010208888",
|
||||||
|
"product_id": "66668888",
|
||||||
|
"empty": " \n",
|
||||||
|
"sign": "ignored",
|
||||||
|
}, "POST")
|
||||||
|
self.assertEqual(sign, "4087a959f3488ecb13efe6ef58e3bc67")
|
||||||
|
|
||||||
|
def test_create_order_posts_json_with_signed_nested_arguments(self):
|
||||||
|
response = Mock()
|
||||||
|
response.json.return_value = {"code": 200, "order_status": 0}
|
||||||
|
self.session.post.return_value = response
|
||||||
|
|
||||||
|
result = self.client.create_order(
|
||||||
|
charge_account="10001",
|
||||||
|
buy_num=2,
|
||||||
|
customer_price="1.20",
|
||||||
|
customer_order_no="merchant-001",
|
||||||
|
product_id="product-1",
|
||||||
|
recharge_arg=[{"templateName": "斗鱼账号", "templateVal": "10001"}],
|
||||||
|
ext_arg={"skuid": 12},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["code"], 200)
|
||||||
|
kwargs = self.session.post.call_args.kwargs
|
||||||
|
self.assertEqual(self.session.post.call_args.args[0], "https://supplier.example/adapter-apiaccess/open/api/createOrderV2")
|
||||||
|
self.assertEqual(kwargs["json"]["customer_price"], "1.2")
|
||||||
|
self.assertEqual(kwargs["json"]["sign"], self.client.sign(kwargs["json"], "POST"))
|
||||||
|
self.assertEqual(kwargs["timeout"], (8, 20))
|
||||||
|
|
||||||
|
def test_query_order_requires_identifier_and_uses_get_params(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, "订单标识"):
|
||||||
|
self.client.query_order()
|
||||||
|
|
||||||
|
response = Mock()
|
||||||
|
response.json.return_value = {"code": 200, "order_status": 2}
|
||||||
|
self.session.get.return_value = response
|
||||||
|
self.client.query_order(customer_order_no="merchant-001")
|
||||||
|
|
||||||
|
kwargs = self.session.get.call_args.kwargs
|
||||||
|
self.assertEqual(self.session.get.call_args.args[0], "https://supplier.example/adapter-apiaccess/open/api/queryOrderV2")
|
||||||
|
self.assertEqual(kwargs["params"]["customer_order_no"], "merchant-001")
|
||||||
|
self.assertEqual(kwargs["params"]["sign"], self.client.sign(kwargs["params"], "GET"))
|
||||||
|
|
||||||
|
def test_missing_configuration_is_rejected_before_request(self):
|
||||||
|
with self.assertRaises(FishFinRechargeConfigError):
|
||||||
|
FishFinRechargeClient(FishFinRechargeConfig(base_url="", app_id="", app_secret=""))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user