From 420ee3d95c3be5ceebbd4327c2ea8b76b64cca5d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 13 Aug 2026 16:44:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=B1=BC=E7=BF=85=E7=9B=B4=E5=85=85=E6=8E=A5=E5=8F=A3=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 ++ core/douyu/__init__.py | 4 + core/douyu/recharge_api.py | 189 ++++++++++++++++++++++++++++ docker-compose.yml | 6 + docs/鱼翅充值api/接入分析.md | 42 +++++++ tests/test_fish_fin_recharge_api.py | 79 ++++++++++++ 6 files changed, 328 insertions(+) create mode 100644 core/douyu/recharge_api.py create mode 100644 docs/鱼翅充值api/接入分析.md create mode 100644 tests/test_fish_fin_recharge_api.py diff --git a/.env.example b/.env.example index d697bf4..b5350d7 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,14 @@ YYB_WORKER_KEY= # YYB_WORKER_PORT=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 邮件验证码读取服务地址(可选;不填则使用默认服务) # MAIL_ROUNDCUBE_URL=http://127.0.0.1:8000/ diff --git a/core/douyu/__init__.py b/core/douyu/__init__.py index e6a7216..73e37c5 100644 --- a/core/douyu/__init__.py +++ b/core/douyu/__init__.py @@ -6,6 +6,7 @@ from .login_api_wgapi import WgapiLoginAPI from .login_api_iframe import IframeLoginAPI from .email_verifier import EmailVerifier from .activity_client import DouyuActivityClient, DouyuActivityError +from .recharge_api import FishFinRechargeClient, FishFinRechargeConfig, FishFinRechargeError __all__ = [ "DouyuLogin", @@ -16,4 +17,7 @@ __all__ = [ "EmailVerifier", "DouyuActivityClient", "DouyuActivityError", + "FishFinRechargeClient", + "FishFinRechargeConfig", + "FishFinRechargeError", ] diff --git a/core/douyu/recharge_api.py b/core/douyu/recharge_api.py new file mode 100644 index 0000000..bb7c988 --- /dev/null +++ b/core/douyu/recharge_api.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index 6fa4e54..30c001c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,12 @@ services: # Worker 已合并到本容器,仅通过回环地址访问。 - YYB_WORKER_URL=http://127.0.0.1:8810 - 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 邮件验证码读取服务地址(不填则使用代码默认值) - MAIL_ROUNDCUBE_URL=${MAIL_ROUNDCUBE_URL:-} # 生产容器禁止 reload;开发环境由 dev.sh 单独启动 reload。 diff --git a/docs/鱼翅充值api/接入分析.md b/docs/鱼翅充值api/接入分析.md new file mode 100644 index 0000000..7516485 --- /dev/null +++ b/docs/鱼翅充值api/接入分析.md @@ -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 +``` + +确认上述信息后,下一步是在后台增加订单表、受权限保护的创建/查询接口、回调幂等处理,再将工作台的“充值鱼翅”从扫码路径显式分流为“扫码支付”和“供应商直充”两种方式。 diff --git a/tests/test_fish_fin_recharge_api.py b/tests/test_fish_fin_recharge_api.py new file mode 100644 index 0000000..e839f24 --- /dev/null +++ b/tests/test_fish_fin_recharge_api.py @@ -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()