优化斗鱼充值

This commit is contained in:
yml2213
2026-08-13 21:43:27 +08:00
parent 902e669614
commit 0bf69c1240
19 changed files with 450 additions and 266 deletions
+2
View File
@@ -76,6 +76,8 @@ FISH_FIN_RECHARGE_BASE_URL=
FISH_FIN_RECHARGE_APP_ID=
FISH_FIN_RECHARGE_APP_KEY=
FISH_FIN_RECHARGE_APP_SECRET=
# 公网回调地址;不填则只通过 queryOrderV2 轮询订单状态。
FISH_FIN_RECHARGE_NOTIFY_URL=
# FISH_FIN_RECHARGE_TIMEOUT=20
# 排查供应商协议时临时开启;会输出完整请求、签名、AppSecret 和响应,排查后必须关闭。
FISH_FIN_RECHARGE_DEBUG=false
+2 -1
View File
@@ -6,7 +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
from .recharge_api import FishFinRechargeClient, FishFinRechargeConfig, FishFinRechargeConfigError, FishFinRechargeError
__all__ = [
"DouyuLogin",
@@ -19,5 +19,6 @@ __all__ = [
"DouyuActivityError",
"FishFinRechargeClient",
"FishFinRechargeConfig",
"FishFinRechargeConfigError",
"FishFinRechargeError",
]
+35 -31
View File
@@ -29,6 +29,7 @@ class FishFinRechargeConfig:
app_id: str
app_secret: str
app_key: str = ""
notify_url: str = ""
timeout: tuple[float, float] = (8, 20)
debug: bool = False
@@ -41,6 +42,7 @@ class FishFinRechargeConfig:
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(),
notify_url=os.getenv("FISH_FIN_RECHARGE_NOTIFY_URL", "").strip(),
timeout=(8, timeout),
debug=os.getenv("FISH_FIN_RECHARGE_DEBUG", "false").strip().lower() in {"1", "true", "yes"},
)
@@ -195,71 +197,73 @@ class FishFinRechargeClient:
return payload
@staticmethod
def _price(value: Decimal | int | float | str) -> str:
def _amount(value: Decimal | int | float | str) -> str:
"""规范化金额,避免浮点数表达式进入签名或订单请求。"""
try:
price = Decimal(str(value))
except (InvalidOperation, ValueError) as exc:
raise ValueError("customer_price 必须是有效金额") from exc
raise ValueError("pay_amount 必须是有效金额") from exc
if not price.is_finite() or price <= 0:
raise ValueError("customer_price 必须大于 0")
raise ValueError("pay_amount 必须大于 0")
return format(price.normalize(), "f")
@classmethod
def _json_price(cls, value: Decimal | int | float | str) -> int | float:
def _json_amount(cls, value: Decimal | int | float | str) -> int | float:
"""按文档以 JSON 数字发送金额,整数不附带无意义的小数位。"""
price_text = cls._price(value)
return int(price_text) if "." not in price_text else float(price_text)
amount_text = cls._amount(value)
return int(amount_text) if "." not in amount_text else float(amount_text)
@staticmethod
def _json_argument(value: list[dict[str, Any]] | dict[str, Any], field: str) -> str:
"""将供应商规定的扩展参数编码为紧凑 JSON 字符串。"""
if not value:
raise ValueError(f"{field} 不能为空")
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def create_order(
self,
*,
charge_account: str,
buy_num: int,
customer_price: Decimal | int | float | str,
customer_order_no: str,
pay_amount: Decimal | int | float | str,
out_order_id: str,
product_id: str,
recharge_arg: list[dict[str, Any]] | None = None,
recharge_arg: list[dict[str, Any]],
order_type: int = 0,
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(out_order_id).strip():
raise ValueError("out_order_id 不能为空")
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 recharge_arg is not None and (not isinstance(recharge_arg, list) or not recharge_arg):
if not isinstance(order_type, int) or isinstance(order_type, bool) or order_type not in {0, 1, 2, 3}:
raise ValueError("order_type 必须是 0 至 3 的整数")
if not isinstance(recharge_arg, list) or not recharge_arg:
raise ValueError("recharge_arg 必须是非空数组")
params: dict[str, Any] = {
"charge_account": str(charge_account).strip(),
"buy_num": buy_num,
# 文档示例使用 JSON 数字;签名使用实际 JSON 数字对应的文本。
"customer_price": self._json_price(customer_price),
"customer_order_no": str(customer_order_no).strip(),
"pay_amount": self._json_amount(pay_amount),
"out_order_id": str(out_order_id).strip(),
"product_id": str(product_id).strip(),
"order_type": order_type,
"recharge_arg": self._json_argument(recharge_arg, "recharge_arg"),
}
# 文档规定可选参数未特别约定时默认不传,不能以空值或内部字段占位。
if recharge_arg:
params["recharge_arg"] = recharge_arg
if str(notify_url).strip():
params["notify_url"] = str(notify_url).strip()
if ext_arg:
params["ext_arg"] = ext_arg
params["ext_arg"] = self._json_argument(ext_arg, "ext_arg")
return self._request("POST", self.CREATE_ORDER_PATH, params)
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 query_order(self, out_order_id: str) -> dict[str, Any]:
"""按新版文档使用来源/外部订单号查询订单。"""
out_order_id = str(out_order_id).strip()
if not out_order_id:
raise ValueError("out_order_id 不能为空")
return self._request("GET", self.QUERY_ORDER_PATH, {"out_order_id": out_order_id})
def account_info(self) -> dict[str, Any]:
"""查询商户账户信息。"""
+1
View File
@@ -45,6 +45,7 @@ services:
- 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_NOTIFY_URL=${FISH_FIN_RECHARGE_NOTIFY_URL:-}
- FISH_FIN_RECHARGE_TIMEOUT=${FISH_FIN_RECHARGE_TIMEOUT:-20}
- FISH_FIN_RECHARGE_DEBUG=${FISH_FIN_RECHARGE_DEBUG:-false}
# Roundcube 邮件验证码读取服务地址(不填则使用代码默认值)
+8 -7
View File
@@ -16,22 +16,22 @@
- 商户账户查询 `userInfoV2`
- 环境变量配置和请求、响应格式校验。
嵌套的 `recharge_arg``ext_arg` 在签名中使用稳定的紧凑 JSON 字符串。该序列化方式需要供应商联调确认;若其服务端使用其他规则,应在 `FishFinRechargeClient._sign_value` 中按确认规则调整
新版订单文档规定:`recharge_arg``ext_arg` 都是 JSON 字符串,不是 JSON 数组或对象。客户端以紧凑 JSON 文本发送并将该文本原样参与签名
## 已确认商品
供应商商品列表中,鱼翅商品为“鱼翅-1元”:`goodsNo=111570`、单份供货成本 `0.993`。项目将它作为 API 渠道的默认商品参数。用户在工作台填写的充值面值同时传为 `buy_num``customer_price`,例如填写 `10` 会为每个勾选账号创建 `buy_num=10``customer_price=10` 的订单;整数金额以 JSON 整数发送,例如 `1` 而不是 `1.0`。供货成本不参与下单金额。
供应商商品列表中,鱼翅商品为“鱼翅-1元”:`goodsNo=111570`、单份供货成本 `0.993`。项目将它作为 API 渠道的默认商品参数。用户在工作台填写的充值面值同时传为 `buy_num``pay_amount`,例如填写 `10` 会为每个勾选账号创建 `buy_num=10``pay_amount=10``order_type=0`直充订单;整数金额以 JSON 整数发送,例如 `1` 而不是 `1.0`。供货成本不参与下单金额。
创建订单只会发送文档示例中的必填字段和 UID 对应的 `recharge_arg``notify_url` 与未约定的 `ext_arg` 不会发送或参与签名。`ext_arg` 仅在供应商明确约定 `skuid``skuname` 等字段后才可启用。
创建订单使用新版必填字段 `product_id``buy_num``pay_amount``out_order_id``order_type`,其中 `out_order_id` 是稳定的 `DYGF{任务ID}`。UID 通过 JSON 字符串 `recharge_arg` 传递。`notify_url` 与未约定的 `ext_arg` 不会发送或参与签名。`ext_arg` 仅在供应商明确约定 `skuid``skuname` 等字段后才可启用。
查询订单使用 `GET queryOrderV2?out_order_id=...`。服务端会轮询该订单号;如果设置 `FISH_FIN_RECHARGE_NOTIFY_URL`,也会发送回调地址至供应商。回调地址为 `/api/douyu/supplier-recharge/callback`,仅接受通过 `POST` 签名验证的回调并按 `out_order_id` 幂等更新任务。
## 接入前待供应商确认
1. API 网关基地址(文档只给出相对路径)。
2. `AppKey` 的实际传输位置与字段名。当前协议示例只签名并发送 `app_id`,客户端不会猜测发送 `AppKey`
3. `queryOrderV2` 所需的订单标识字段名(商户单号、供应商单号或两者)
4. 创建订单完整字段和 `recharge_arg` 模板,尤其是鱼翅商品 `product_id`、价格、数量含义,以及是否必须传 `ext_arg`
5. `recharge_arg` / `ext_arg` 的嵌套签名序列化规则,以及请求与响应是否必须验签。
6. 回调地址、回调重试与回调验签规则;生产接入应持久化商户订单号并实现幂等处理。
3. 鱼翅商品 `product_id``pay_amount``buy_num` 的最终业务约束,以及是否必须传 `ext_arg`
4. 回调地址的公网可达性与回调重试策略
## 运行时配置
@@ -42,6 +42,7 @@ 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_NOTIFY_URL=https://your-domain.example/api/douyu/supplier-recharge/callback
FISH_FIN_RECHARGE_TIMEOUT=20
```
@@ -0,0 +1,77 @@
# 创建订单【必要】
# 接口说明
- 下单接口
- POST 请求
- application/json
- 接口地址 adapter-apiaccess/open/api/createOrderV2
# 请求参数
请求公共参数 —— 参考接口规范的请求公共参数 是 携带公共参数
product_id string 66668888 是 商品编码
buy_num int 1 是 购买数量
pay_amount decimal 88.88 是 用户支付金额
out_order_id string OUT202503010010208888 是 来源/外部订单号
order_type int 是 订单类型:0-直充 1-话费 2-流量 3-卡密
recharge_arg string 用户填写信息的JSON串 否 【直充必传】充值模板及填写值
buyer_ip string 114.115.116.117 否 买家IP,若要启用风控则必传
buyer_id string64 15486548471 否 买家唯一标识,若要启用风控则必传
notify_url string 否 回调地址,订单完成会通过该地址异步通知
ext_arg string 订单补充信息JSON串 否 补充字段,经过约定后,补充信息字段都在这里传递
notify_url
- 一些直充类的订单时间比较久,但不能持续占用连接
- 不传就不回调,订单的状态在查询时处理
recharge_arg:(此处数据为用户填写和对应模板)
[
{
"templateName": "模板名,一般是中文,如手机号,只是标识用,调用方可自行决定",
"templateVal": "用户填写的充值账号,比如:19876543210",
}
]
这里是用户填写的信息,比如:充值手机号:19876543210
templateName:充值手机号
templateVal19876543210
ext_arg:(此处数据均为双方约定)
{
"skuid": 243241231238888888,
"skuname: "电商平台的商品名称"
}
# 响应信息
## code数据对业务流程的影响
参数 类型 示例值 必要 描述
响应公共参数 —— 参考接口规范的响应公共参数 是 携带公共参数
请求异常情况下,下面的参数都是非必要的下标描述的是请求接收正常情况下的参数说明
out_order_id string OUT202503010010208888 是 来源/外部订单号
order_id string 202503010010208888 是 供货方的订单号
order_type int 4 是 订单类型:0-直充;1-话费;2-流量;3-卡密
order_status int 0 是 0,待处理;1,处理中;2,成功;3,失败 4-异常
fail_reason string 否 失败原因,失败必传
create_time string 2025/3/1 0:10 是 订单创建时间(供货方)
code数据对业务流程的影响
- 如果返回 200 的状态码,则下单成功,后续通过【查询订单】或者【订单异步通知】来获取订单结果状态
- 如果返回非 200 的状态码,订单失败,流程结束
order_status数据对业务流程的影响
- 0,待处理;1,处理中;2,成功;3,失败; 4,异常
- 当为 0 和 1 时,订单没有结束,
- 需要调用方自轮询查询订单;
- 或本平台订单结束后,异步通知订单结果
@@ -0,0 +1,44 @@
查询订单【必要】
接口说明
- 订单查询接口
- GET 请求
- application/x-www-form-urlencoded
- 接口地址 adapter-apiaccess/open/api/queryOrderV2
请求参数
参数 类型 示例值 必要 描述
请求公共参数 —— 参考接口规范的请求公共参数 是 携带公共参数
out_order_id string OUT202503010010208888 是 来源/外部订单号
响应信息
参数 类型 示例值 必要 描述
响应公共参数 —— 请参考接口规范的响应公共参数 是 携带公共参数
请求异常情况下,下面的参数都是非必要的下标描述的是请求接收正常情况下的参数说明
out_order_id string OUT202503010010208888 是 来源/外部订单号
order_id string 202503010010208888 是 供货方的订单号
order_type int 4 是 订单类型:0-直充;1-话费;2-流量;3-卡密
order_status int 0 是 0,待处理;1,处理中;2,成功;3,失败 4-异常
fail_reason string 否 失败原因,失败必传
create_time string 2025/3/1 0:10 是 订单创建时间(原厂)
finish_time string 2025/3/1 0:10 否 订单完成时间(原厂)
cards string 加密数据 否 卡密订单时才可能有数据,使用ASE加密,加密方式参考附录的数据加密部分
code数据对业务流程的影响
- 查询订单时如果返回非 200 的状态码,订单失败,流程结束
order_status数据对业务流程的影响
- 当为 0 和 1 时,订单没有结束,
- 需要调用方自轮询查询订单;
- 或本平台订单结束后,异步通知订单结果
- 当为 2 和 3 、4 时,订单结束
cards解密后的结构如下
[
{
card_no: 'CN88889999',(无卡号就填""空串)
card_pwd: 'ABCD-DEFG-GHIJ-JKLM',
expired_time: '2026-09-27 08:36:58',(过期时间,可不传)
price: '8.8',(卡密单价,可不传)
card_type: '0'(不传时,默认为0。0.普通卡密 1.短链)
}
]
@@ -0,0 +1,47 @@
# 订单异步通知【推荐】
# 接口说明
- 下单后,如订单流程、时间较长,需要第一时间返回下单成功状态,之后的流程中再去通知外部平台订单的状态
- POST 请求
- 接口地址一般为 ***http://IP******\[:PORT\]/网关/业务/callBackV2***
- 具体地址,请双方开发协定,或联系运营获取
# 请求参数
参数 类型 示例值 必要 描述
请求公共参数 —— 参考接口规范的请求公共参数 是 携带公共参数
out_order_id string OUT202503010010208888 是 来源/外部订单号
order_id string 202503010010208888 是 原厂的订单号
order_type int 4 是 订单类型:0-直充;1-话费;2-流量;3-卡密
order_status int 0 是 "0,待处理;1,处理中
2,成功;3,失败; 4-异常
这里应该只有 2 和 3、4"
fail_reason string 否 失败原因,失败必传
create_time string 2025/3/1 0:10 是 订单创建时间(原厂)
finish_time string 2025/3/1 0:10 是 订单完成时间(原厂)
cards string 加密数据 否 卡密订单时才可能有数据,使用ASE加密,加密方式参考附录的数据加密部分
## cards解密后的结构如下:
```JSON
[
{
card_no: 'CN88889999',""
card_pwd: 'ABCD-DEFG-GHIJ-JKLM',
expired_time: '2026-09-27 08:36:58',
price: '8.8',
card_type: '0'00. 1.
}
]
```
# 响应信息
参数 类型 示例值 必要 描述
公共参数 —— 请参考接口规范的公共响应信息 是 携带公共参数
@@ -1,76 +0,0 @@
# 创建订单【必要】
# 接口说明
- 下单接口
- POST 请求
- application/json
- 接口地址 ***adapter\-apiaccess/open/api/createOrderV2***
# 请求参数
**notify\_url**
- 一些直充类的订单时间比较久,但不能持续占用连接
- 不传就不回调,订单的状态在查询时处理
**recharge\_arg**:(此处数据为用户填写和对应模板)
```Plain Text
[
{
"templateName": "模板名,一般是中文,如手机号,只是标识用,调用方可自行决定",
"templateVal": "用户填写的充值账号,比如:19876543210",
}
]
```
这里是用户填写的信息,比如:充值手机号:19876543210
templateName:充值手机号
templateVal19876543210
**ext\_arg**:(此处数据均为双方约定)
```Plain Text
{
"skuid": 243241231238888888,
"skuname: "电商平台的商品名称"
}
```
# 响应信息
## code数据对业务流程的影响
- 如果返回 200 的状态码,则下单成功,后续通过【查询订单】或者【订单异步通知】来获取订单结果状态
- 如果返回非 200 的状态码,订单失败,流程结束
## order\_status数据对业务流程的影响
- 0,待处理;1,处理中;2,成功;3,失败; 4,异常
- 当为 0 和 1 时,订单没有结束,
- 需要调用方自轮询查询订单;
- 或本平台订单结束后,异步通知订单结果
@@ -1,54 +0,0 @@
# 查询订单【必要】
# 接口说明
- 订单查询接口
- GET 请求
- application/x\-www\-form\-urlencoded
- 接口地址 ***adapter\-apiaccess/open/api/queryOrderV2***
# 请求参数
# 响应信息
## code数据对业务流程的影响
- 查询订单时如果返回非 200 的状态码,订单失败,流程结束
## order\_status数据对业务流程的影响
- 当为 0 和 1 时,订单没有结束,
- 需要调用方自轮询查询订单;
- 或本平台订单结束后,异步通知订单结果
- 当为 2 和 3 、4 时,订单结束
## cards解密后的结构如下
```JSON
[
{
card_no: 'CN88889999',""
card_pwd: 'ABCD-DEFG-GHIJ-JKLM',
expired_time: '2026-09-27 08:36:58',
price: '8.8',
card_type: '0'00. 1.
}
]
```
@@ -1,34 +0,0 @@
# 订单异步通知【推荐】
# 接口说明
- 下单后,如订单流程、时间较长,需要第一时间返回下单成功状态,之后的流程中再去通知外部平台订单的状态
- POST 请求
- 接口地址一般为 ***http://IP******\[:PORT\]/网关/业务/callBackV2***
- 具体地址,请双方开发协定,或联系运营获取
# 请求参数
## cards解密后的结构如下:
```JSON
[
{
card_no: 'CN88889999',""
card_pwd: 'ABCD-DEFG-GHIJ-JKLM',
expired_time: '2026-09-27 08:36:58',
price: '8.8',
card_type: '0'00. 1.
}
]
```
# 响应信息
@@ -1,20 +0,0 @@
# 账户信息查询
# 接口说明
- 下单接口
- GET 请求
- 接口地址 ***adapter\-apiaccess/open/api/userInfoV2***
# 请求参数
# 响应信息
@@ -11,10 +11,15 @@
# 请求参数
参数 类型 示例值 必要 描述
请求公共参数 —— 参考接口规范的请求公共参数 是 携带公共参数
# 响应信息
参数 类型 示例值 必要 描述
响应公共参数 —— 请参考接口规范的响应公共参数 是 携带公共参数
请求异常情况下,下面的参数都是非必要的下标描述的是请求接收正常情况下的参数说明
name string 发财集团 是 商户名称
balance decimal 9999999999999999.99 是 商户余额(单位:元)
status int 1 是 商户状态,0,禁用;1,启用
+53 -7
View File
@@ -1,8 +1,9 @@
"""斗鱼鱼翅充值渠道分流测试。"""
import asyncio
import os
import unittest
from unittest.mock import Mock, patch
from unittest.mock import AsyncMock, Mock, patch
os.environ.setdefault("DATABASE_URL", "sqlite://")
os.environ.setdefault("APP_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
@@ -12,7 +13,9 @@ from sqlalchemy.orm import sessionmaker
from web.backend.database import Base
from web.backend.models import Account, DouyuTask, User
from web.backend.routers.douyu import supplier_recharge_callback
from web.backend.services.douyu_runner import DouyuBatchRunner
from core.douyu import FishFinRechargeClient, FishFinRechargeConfig
class DouyuGoldRechargeChannelTests(unittest.TestCase):
@@ -39,6 +42,9 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
self.session.add(self.task)
self.session.commit()
self.runner = DouyuBatchRunner(self.session, "batch", "create_gold_qr")
os.environ["FISH_FIN_RECHARGE_BASE_URL"] = "https://supplier.example"
os.environ["FISH_FIN_RECHARGE_APP_ID"] = "test-app"
os.environ["FISH_FIN_RECHARGE_APP_SECRET"] = "test-secret"
def tearDown(self):
self.session.close()
@@ -50,7 +56,7 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
supplier = Mock()
supplier.create_order.return_value = {
"code": 200,
"result": {"order_status": 2, "order_no": "supplier-001"},
"result": {"order_status": 2, "order_id": "supplier-001"},
"sign": "not-stored",
}
client_class.return_value = supplier
@@ -67,20 +73,23 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
)
supplier.create_order.assert_called_once_with(
charge_account="10001",
buy_num=10,
customer_price=unittest.mock.ANY,
customer_order_no=f"DYGF{self.task.id}",
pay_amount=unittest.mock.ANY,
out_order_id=f"DYGF{self.task.id}",
product_id="gold-product",
recharge_arg=[{"templateName": "斗鱼账号", "templateVal": "10001"}],
order_type=0,
notify_url=unittest.mock.ANY,
)
self.assertEqual(str(supplier.create_order.call_args.kwargs["customer_price"]), "10")
self.assertEqual(str(supplier.create_order.call_args.kwargs["pay_amount"]), "10")
self.session.refresh(self.task)
self.assertEqual(self.task.status, "success")
self.assertEqual(self.task.result["recharge_channel"], "supplier_api")
self.assertEqual(self.task.result["supplier_order_status"], 2)
self.assertEqual(self.task.result["buy_num"], 10)
self.assertEqual(self.task.result["customer_price"], "10")
self.assertEqual(self.task.result["out_order_id"], f"DYGF{self.task.id}")
self.assertEqual(self.task.result["order_id"], "supplier-001")
self.assertEqual(self.task.result["pay_amount"], "10")
self.assertNotIn("pay_url", self.task.result)
self.assertNotIn("sign", self.task.result["supplier_order"])
@@ -103,6 +112,43 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
self.assertEqual(self.task.status, "failed")
self.assertEqual(self.task.message, "商品已下架")
def test_supplier_callback_verifies_signature_and_updates_terminal_task(self):
self.task.supplier_out_order_id = f"DYGF{self.task.id}"
self.task.result = {"recharge_channel": "supplier_api", "out_order_id": self.task.supplier_out_order_id}
self.session.commit()
client = FishFinRechargeClient(FishFinRechargeConfig(
base_url="https://supplier.example", app_id="test-app", app_secret="test-secret",
))
payload = {
"app_id": "test-app",
"timestamp": 1786629000,
"out_order_id": self.task.supplier_out_order_id,
"order_id": "supplier-001",
"order_type": 0,
"order_status": 2,
"create_time": "2026/8/13 21:30",
}
payload["sign"] = client.sign(payload, "POST")
request = Mock()
request.json = AsyncMock(return_value=payload)
response = asyncio.run(supplier_recharge_callback(request, self.session))
self.assertEqual(response["code"], 200)
self.assertEqual(response["message"], "success")
self.assertTrue(client.verify_response_sign(response, "POST"))
self.session.refresh(self.task)
self.assertEqual(self.task.status, "success")
self.assertEqual(self.task.message, "供应商直充成功(异步通知)")
self.session.refresh(self.account)
self.assertEqual(self.account.bind_status, "gold_recharged")
self.assertEqual(self.task.result["order_id"], "supplier-001")
self.assertEqual(self.task.result["supplier_order_status"], 2)
# 同一终态回调可重复投递,不会改写已结束任务。
asyncio.run(supplier_recharge_callback(request, self.session))
self.session.refresh(self.task)
self.assertEqual(self.task.status, "success")
if __name__ == "__main__":
unittest.main()
+26 -19
View File
@@ -34,16 +34,15 @@ class FishFinRechargeClientTests(unittest.TestCase):
}, "POST")
self.assertEqual(sign, "4087a959f3488ecb13efe6ef58e3bc67")
def test_create_order_posts_json_with_signed_optional_arguments(self):
def test_create_order_posts_new_document_payload(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",
pay_amount="1.20",
out_order_id="OUT202503010010208888",
product_id="product-1",
recharge_arg=[{"templateName": "斗鱼账号", "templateVal": "10001"}],
ext_arg={"skuid": 12},
@@ -52,7 +51,14 @@ class FishFinRechargeClientTests(unittest.TestCase):
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"]["pay_amount"], 1.2)
self.assertEqual(kwargs["json"]["out_order_id"], "OUT202503010010208888")
self.assertEqual(kwargs["json"]["order_type"], 0)
self.assertEqual(kwargs["json"]["recharge_arg"], '[{"templateName":"斗鱼账号","templateVal":"10001"}]')
self.assertEqual(kwargs["json"]["ext_arg"], '{"skuid":12}')
self.assertNotIn("charge_account", kwargs["json"])
self.assertNotIn("customer_price", kwargs["json"])
self.assertNotIn("customer_order_no", kwargs["json"])
self.assertEqual(kwargs["json"]["sign"], self.client.sign(kwargs["json"], "POST"))
self.assertEqual(kwargs["timeout"], (8, 20))
@@ -62,29 +68,30 @@ class FishFinRechargeClientTests(unittest.TestCase):
self.session.post.return_value = response
self.client.create_order(
charge_account="10001", buy_num=1, customer_price="1.0",
customer_order_no="merchant-001", product_id="111570",
buy_num=1, pay_amount="1.0",
out_order_id="merchant-001", product_id="111570",
recharge_arg=[{"templateName": "斗鱼UID", "templateVal": "10001"}],
)
body = self.session.post.call_args.kwargs["json"]
self.assertEqual(body["customer_price"], 1)
self.assertEqual(body["pay_amount"], 1)
self.assertNotIn("notify_url", body)
self.assertNotIn("recharge_arg", body)
self.assertEqual(body["recharge_arg"], '[{"templateName":"斗鱼UID","templateVal":"10001"}]')
self.assertNotIn("ext_arg", body)
self.assertEqual(body["sign"], self.client.sign(body, "POST"))
def test_query_order_requires_identifier_and_uses_get_params(self):
with self.assertRaisesRegex(ValueError, "订单标识"):
self.client.query_order()
with self.assertRaisesRegex(ValueError, "out_order_id"):
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")
self.client.query_order("OUT202503010010208888")
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"]["out_order_id"], "OUT202503010010208888")
self.assertEqual(kwargs["params"]["sign"], self.client.sign(kwargs["params"], "GET"))
def test_missing_configuration_is_rejected_before_request(self):
@@ -100,15 +107,15 @@ class FishFinRechargeClientTests(unittest.TestCase):
client = FishFinRechargeClient(self.config, session=self.session, trace=events.append)
client.create_order(
charge_account="10001", buy_num=1, customer_price="0.993",
customer_order_no="merchant-001", product_id="111570",
buy_num=1, pay_amount="1",
out_order_id="merchant-001", product_id="111570",
recharge_arg=[{"templateName": "斗鱼UID", "templateVal": "10001"}],
)
self.assertEqual([event["stage"] for event in events], ["request", "response"])
self.assertNotIn("sign", events[0]["params"])
self.assertNotIn("recharge_arg", events[0]["params"])
self.assertEqual(events[0]["params"]["customer_price"], 0.993)
self.assertEqual(events[0]["params"]["pay_amount"], 1)
def test_debug_trace_includes_complete_request_and_response_bodies(self):
response = Mock()
@@ -125,13 +132,13 @@ class FishFinRechargeClientTests(unittest.TestCase):
client = FishFinRechargeClient(config, session=self.session, trace=events.append)
client.create_order(
charge_account="10001", buy_num=1, customer_price="0.993",
customer_order_no="merchant-001", product_id="111570",
buy_num=1, pay_amount="1",
out_order_id="merchant-001", product_id="111570",
recharge_arg=[{"templateName": "斗鱼UID", "templateVal": "10001"}],
)
self.assertEqual(events[0]["json_body"]["sign"], client.sign(events[0]["json_body"], "POST"))
self.assertEqual(events[0]["json_body"]["customer_price"], 0.993)
self.assertEqual(events[0]["json_body"]["pay_amount"], 1)
self.assertNotIn("sign", events[0]["sign_params"])
self.assertIn(self.config.app_secret, events[0]["sign_source"])
self.assertEqual(events[1]["response_body"]["sign"], "response-sign")
@@ -0,0 +1,44 @@
"""持久化斗鱼供应商直充外部订单号
Revision ID: 20260813_0027
Revises: 20260813_0026
Create Date: 2026-08-13
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260813_0027"
down_revision: Union[str, None] = "20260813_0026"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("douyu_tasks"):
return
columns = {column["name"] for column in inspector.get_columns("douyu_tasks")}
if "supplier_out_order_id" not in columns:
op.add_column("douyu_tasks", sa.Column("supplier_out_order_id", sa.String(length=64), nullable=True))
op.create_index(
"ix_douyu_tasks_supplier_out_order_id",
"douyu_tasks",
["supplier_out_order_id"],
unique=True,
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("douyu_tasks"):
return
columns = {column["name"] for column in inspector.get_columns("douyu_tasks")}
if "supplier_out_order_id" in columns:
op.drop_index("ix_douyu_tasks_supplier_out_order_id", table_name="douyu_tasks")
op.drop_column("douyu_tasks", "supplier_out_order_id")
+2
View File
@@ -119,6 +119,8 @@ class DouyuTask(Base):
task_type = Column(String(64), nullable=False, index=True)
# 任务归属工作台;避免同一账号的精英/电竞/小店任务在前端串行展示或串弹二维码。
handbook_scope = Column(String(16), nullable=False, default="legacy", index=True)
# 供应商直充的来源订单号,用于轮询与异步回调的幂等关联。
supplier_out_order_id = Column(String(64), nullable=True, unique=True, index=True)
status = Column(String(32), default="pending")
message = Column(String(512), default="")
result = Column(JSON, nullable=True)
+76 -1
View File
@@ -6,7 +6,8 @@ import asyncio
import threading
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
from loguru import logger
from sqlalchemy import or_
from sqlalchemy.orm import Session, joinedload
@@ -37,11 +38,85 @@ from ..services.douyu_service import (
douyu_task_payload,
ensure_douyu_config,
)
from core.douyu import FishFinRechargeClient, FishFinRechargeConfig, FishFinRechargeConfigError
router = APIRouter(prefix="/api/douyu", tags=["斗鱼活动"])
def _supplier_response_value(payload: dict, *keys: str):
"""兼容供应商把订单字段放在根节点、data 或 result 中。"""
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
for source in (payload, data, result):
for key in keys:
if source.get(key) is not None:
return source[key]
return None
@router.post("/supplier-recharge/callback")
async def supplier_recharge_callback(request: Request, db: Session = Depends(get_db)):
"""接收供应商直充终态通知,验签后按 out_order_id 幂等更新任务。"""
try:
payload = await request.json()
except ValueError as exc:
raise HTTPException(status_code=400, detail="供应商回调不是 JSON") from exc
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="供应商回调格式无效")
try:
client = FishFinRechargeClient(FishFinRechargeConfig.from_env())
except FishFinRechargeConfigError as exc:
logger.error("[douyu] 供应商直充回调配置无效: {}", exc)
raise HTTPException(status_code=503, detail="供应商直充回调未配置") from exc
if not client.verify_response_sign(payload, "POST"):
logger.warning("[douyu] 供应商直充回调验签失败: keys={}", sorted(payload))
raise HTTPException(status_code=401, detail="供应商回调签名无效")
out_order_id = str(_supplier_response_value(payload, "out_order_id") or "").strip()
if not out_order_id:
raise HTTPException(status_code=400, detail="供应商回调缺少 out_order_id")
task = (
db.query(DouyuTask)
.filter(DouyuTask.supplier_out_order_id == out_order_id, DouyuTask.task_type == "create_gold_qr")
.first()
)
if not task:
raise HTTPException(status_code=404, detail="供应商回调订单不存在")
status = DouyuBatchRunner._to_int(_supplier_response_value(payload, "order_status", "orderStatus"))
result = dict(task.result) if isinstance(task.result, dict) else {}
result.update({
"out_order_id": out_order_id,
"order_id": _supplier_response_value(payload, "order_id", "orderId") or result.get("order_id"),
"supplier_order_status": status,
"supplier_order": DouyuBatchRunner._supplier_result(payload),
"supplier_callback_received": True,
})
if task.status not in {"success", "failed", "stopped"}:
if status == 2:
task.status = "success"
task.message = "供应商直充成功(异步通知)"
task.finished_at = datetime.now(timezone.utc)
if task.account:
task.account.bind_status = "gold_recharged"
task.account.updated_at = datetime.now(timezone.utc)
elif status in {3, 4}:
reason = str(_supplier_response_value(payload, "fail_reason", "message", "msg") or "供应商直充失败")
task.status = "failed"
task.message = reason[:512]
task.finished_at = datetime.now(timezone.utc)
else:
task.status = "running"
task.message = f"供应商直充订单处理中(状态 {status if status is not None else '-'}"
task.result = result
db.commit()
logger.info("[douyu] 供应商直充回调已处理: out_order_id={} status={}", out_order_id, status)
acknowledgement = {"code": 200, "message": "success"}
acknowledgement["sign"] = client.sign(acknowledgement, "POST")
return acknowledgement
def _can_view_all(user: User) -> bool:
return user_has_permission(user, "account:view_all")
+25 -13
View File
@@ -740,7 +740,7 @@ class DouyuBatchRunner:
@classmethod
def _supplier_order_status(cls, payload: dict) -> int | None:
"""提取供应商订单状态,文档约定 0-4。"""
return cls._to_int(cls._supplier_value(payload, "order_status", "orderStatus"))
return cls._to_int(cls._supplier_value(payload, "order_status", "orderStatus", "supplier_order_status"))
@classmethod
def _supplier_message(cls, payload: dict) -> str:
@@ -768,13 +768,20 @@ class DouyuBatchRunner:
result: dict,
) -> int | None:
"""轮询供应商直充订单至结束状态。"""
order_no = str(result["customer_order_no"])
order_no = str(result["out_order_id"])
deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS
poll_count = 0
result["payment_polling"] = True
while not self._stop.is_set() and time.monotonic() <= deadline:
try:
payload = client.query_order(customer_order_no=order_no)
# 回调可能已在另一个数据库会话中结束订单,刷新后直接使用其结果。
db.refresh(task)
if task.status in {"success", "failed"}:
callback_result = task.result if isinstance(task.result, dict) else result
result.update(callback_result)
result["payment_polling"] = False
return self._supplier_order_status(callback_result)
payload = client.query_order(order_no)
code = self._to_int(self._supplier_value(payload, "code"))
status = self._supplier_order_status(payload)
poll_count += 1
@@ -2505,10 +2512,12 @@ class DouyuBatchRunner:
if not charge_account:
raise FishFinRechargeError("账号缺少斗鱼 UID,无法发起供应商直充")
# task.id 是唯一且稳定的商户单号来源,重试时不会创建不同的供应商订单。
# task.id 是唯一且稳定的外部订单号来源,重试时不会创建不同的供应商订单。
order_no = f"DYGF{task.id}"
# customer_price 是用户选择的充值面值;goodsFaceValue=0.993 是供货成本,不能作为支付金额。
customer_price = Decimal(amount)
task.supplier_out_order_id = order_no
db.commit()
# pay_amount 是用户选择的充值面值;goodsFaceValue=0.993 是供货成本,不能作为支付金额。
pay_amount = Decimal(amount)
def trace(event: dict) -> None:
"""将脱敏供应商协议信息输出到任务日志,便于线上联调。"""
stage = event.get("stage")
@@ -2517,8 +2526,8 @@ class DouyuBatchRunner:
self._push_log(
"info",
"供应商直充请求 "
f"path={event.get('path')} uid={params.get('charge_account')} "
f"buy_num={params.get('buy_num')} customer_price={params.get('customer_price')} "
f"path={event.get('path')} out_order_id={params.get('out_order_id')} "
f"buy_num={params.get('buy_num')} pay_amount={params.get('pay_amount')} "
f"product_id={params.get('product_id')} types={event.get('parameter_types')} "
f"sign_digest={event.get('sign_digest')}",
)
@@ -2550,22 +2559,25 @@ class DouyuBatchRunner:
client = FishFinRechargeClient(FishFinRechargeConfig.from_env(), trace=trace)
order_payload = client.create_order(
charge_account=charge_account,
buy_num=amount,
customer_price=customer_price,
customer_order_no=order_no,
pay_amount=pay_amount,
out_order_id=order_no,
product_id=product_id,
recharge_arg=[{"templateName": template_name, "templateVal": charge_account}],
order_type=0,
notify_url=client.config.notify_url,
)
code = self._to_int(self._supplier_value(order_payload, "code"))
status = self._supplier_order_status(order_payload)
result = {
"recharge_channel": "supplier_api",
"customer_order_no": order_no,
"out_order_id": order_no,
"order_id": self._supplier_value(order_payload, "order_id", "orderId"),
"charge_account": charge_account,
"buy_num": amount,
"product_id": product_id,
"customer_price": format(customer_price.normalize(), "f"),
"pay_amount": format(pay_amount.normalize(), "f"),
"order_type": 0,
"supplier_code": code,
"supplier_order_status": status,
"supplier_order": self._supplier_result(order_payload),