功能: 接入鱼翅供应商直充渠道

This commit is contained in:
yml2213
2026-08-13 21:11:20 +08:00
parent 420ee3d95c
commit 902e669614
14 changed files with 586 additions and 15 deletions
+2
View File
@@ -77,6 +77,8 @@ 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=20 # FISH_FIN_RECHARGE_TIMEOUT=20
# 排查供应商协议时临时开启;会输出完整请求、签名、AppSecret 和响应,排查后必须关闭。
FISH_FIN_RECHARGE_DEBUG=false
# Roundcube 邮件验证码读取服务地址(可选;不填则使用默认服务) # Roundcube 邮件验证码读取服务地址(可选;不填则使用默认服务)
# MAIL_ROUNDCUBE_URL=http://127.0.0.1:8000/ # MAIL_ROUNDCUBE_URL=http://127.0.0.1:8000/
+87 -10
View File
@@ -8,7 +8,7 @@ import os
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from typing import Any, Mapping from typing import Any, Callable, Mapping
import requests import requests
@@ -30,6 +30,7 @@ class FishFinRechargeConfig:
app_secret: str app_secret: str
app_key: str = "" app_key: str = ""
timeout: tuple[float, float] = (8, 20) timeout: tuple[float, float] = (8, 20)
debug: bool = False
@classmethod @classmethod
def from_env(cls) -> "FishFinRechargeConfig": def from_env(cls) -> "FishFinRechargeConfig":
@@ -41,6 +42,7 @@ class FishFinRechargeConfig:
app_secret=os.getenv("FISH_FIN_RECHARGE_APP_SECRET", "").strip(), app_secret=os.getenv("FISH_FIN_RECHARGE_APP_SECRET", "").strip(),
app_key=os.getenv("FISH_FIN_RECHARGE_APP_KEY", "").strip(), app_key=os.getenv("FISH_FIN_RECHARGE_APP_KEY", "").strip(),
timeout=(8, timeout), timeout=(8, timeout),
debug=os.getenv("FISH_FIN_RECHARGE_DEBUG", "false").strip().lower() in {"1", "true", "yes"},
) )
def validate(self) -> None: def validate(self) -> None:
@@ -65,11 +67,18 @@ class FishFinRechargeClient:
QUERY_ORDER_PATH = "/adapter-apiaccess/open/api/queryOrderV2" QUERY_ORDER_PATH = "/adapter-apiaccess/open/api/queryOrderV2"
USER_INFO_PATH = "/adapter-apiaccess/open/api/userInfoV2" USER_INFO_PATH = "/adapter-apiaccess/open/api/userInfoV2"
def __init__(self, config: FishFinRechargeConfig, *, session: requests.Session | None = None): def __init__(
self,
config: FishFinRechargeConfig,
*,
session: requests.Session | None = None,
trace: Callable[[dict[str, Any]], None] | None = None,
):
config.validate() config.validate()
self.config = config self.config = config
self.session = session or requests.Session() self.session = session or requests.Session()
self.session.trust_env = False self.session.trust_env = False
self.trace = trace or (lambda _event: None)
@staticmethod @staticmethod
def _sign_value(value: Any) -> str: def _sign_value(value: Any) -> str:
@@ -100,6 +109,11 @@ class FishFinRechargeClient:
raw = f"{query}{method.upper()}{self.config.app_secret}" raw = f"{query}{method.upper()}{self.config.app_secret}"
return hashlib.md5(raw.encode("utf-8")).hexdigest() return hashlib.md5(raw.encode("utf-8")).hexdigest()
@staticmethod
def _json_text(value: Any) -> str:
"""生成单行 JSON 调试文本,避免日志被控制字符截断。"""
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str)
def verify_response_sign(self, payload: Mapping[str, Any], method: str) -> bool: def verify_response_sign(self, payload: Mapping[str, Any], method: str) -> bool:
"""校验带 sign 的响应;供应商未返回 sign 时由调用方决定是否接受。""" """校验带 sign 的响应;供应商未返回 sign 时由调用方决定是否接受。"""
received = str(payload.get("sign") or "").strip().lower() received = str(payload.get("sign") or "").strip().lower()
@@ -114,6 +128,35 @@ class FishFinRechargeClient:
} }
request_params["sign"] = self.sign(request_params, method) request_params["sign"] = self.sign(request_params, method)
url = f"{self.config.base_url.rstrip('/')}{path}" url = f"{self.config.base_url.rstrip('/')}{path}"
trace_event = {
"stage": "request",
"method": method.upper(),
"path": path,
"params": {
key: value
for key, value in request_params.items()
if key not in {"sign", "recharge_arg", "ext_arg"}
},
"parameter_types": {
key: type(value).__name__
for key, value in request_params.items()
if key != "sign"
},
# 只记录摘要,便于关联排查而不暴露可重放的签名。
"sign_digest": hashlib.sha256(request_params["sign"].encode("utf-8")).hexdigest()[:12],
}
if self.config.debug:
sign_params = self.normalized_params(request_params)
sign_query = "&".join(f"{key}={sign_params[key]}" for key in sorted(sign_params))
trace_event.update({
"url": url,
"content_type": "application/json",
# 调试模式按用户要求保留完整协议内容,包含 sign 和 AppSecret。
"json_body": request_params,
"sign_params": sign_params,
"sign_source": f"{sign_query}{method.upper()}{self.config.app_secret}",
})
self.trace(trace_event)
try: try:
if method.upper() == "GET": if method.upper() == "GET":
response = self.session.get(url, params=request_params, timeout=self.config.timeout) response = self.session.get(url, params=request_params, timeout=self.config.timeout)
@@ -127,6 +170,28 @@ class FishFinRechargeClient:
raise FishFinRechargeError("供应商响应不是 JSON") from exc raise FishFinRechargeError("供应商响应不是 JSON") from exc
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise FishFinRechargeError("供应商响应格式无效") raise FishFinRechargeError("供应商响应格式无效")
trace_event = {
"stage": "response",
"method": method.upper(),
"path": path,
"http_status": response.status_code,
"code": payload.get("code"),
"message": payload.get("msg") or payload.get("message") or "",
"keys": sorted(payload.keys()),
"data_keys": sorted((payload.get("data") or {}).keys()) if isinstance(payload.get("data"), dict) else [],
"result_keys": sorted((payload.get("result") or {}).keys()) if isinstance(payload.get("result"), dict) else [],
}
if self.config.debug:
trace_event.update({
"response_headers": {
key: value
for key, value in response.headers.items()
if key.lower() in {"content-type", "x-request-id", "request-id"}
},
"response_body": payload,
"response_text": response.text,
})
self.trace(trace_event)
return payload return payload
@staticmethod @staticmethod
@@ -140,6 +205,12 @@ class FishFinRechargeClient:
raise ValueError("customer_price 必须大于 0") raise ValueError("customer_price 必须大于 0")
return format(price.normalize(), "f") return format(price.normalize(), "f")
@classmethod
def _json_price(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)
def create_order( def create_order(
self, self,
*, *,
@@ -148,7 +219,7 @@ class FishFinRechargeClient:
customer_price: Decimal | int | float | str, customer_price: Decimal | int | float | str,
customer_order_no: str, customer_order_no: str,
product_id: str, product_id: str,
recharge_arg: list[dict[str, Any]], recharge_arg: list[dict[str, Any]] | None = None,
notify_url: str = "", notify_url: str = "",
ext_arg: dict[str, Any] | None = None, ext_arg: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -161,18 +232,24 @@ class FishFinRechargeClient:
raise ValueError("product_id 不能为空") raise ValueError("product_id 不能为空")
if not isinstance(buy_num, int) or isinstance(buy_num, bool) or buy_num < 1: if not isinstance(buy_num, int) or isinstance(buy_num, bool) or buy_num < 1:
raise ValueError("buy_num 必须是不小于 1 的整数") raise ValueError("buy_num 必须是不小于 1 的整数")
if not isinstance(recharge_arg, list) or not recharge_arg: if recharge_arg is not None and (not isinstance(recharge_arg, list) or not recharge_arg):
raise ValueError("recharge_arg 必须是非空数组") raise ValueError("recharge_arg 必须是非空数组")
return self._request("POST", self.CREATE_ORDER_PATH, { params: dict[str, Any] = {
"charge_account": str(charge_account).strip(), "charge_account": str(charge_account).strip(),
"buy_num": buy_num, "buy_num": buy_num,
"customer_price": self._price(customer_price), # 文档示例使用 JSON 数字;签名使用实际 JSON 数字对应的文本。
"customer_price": self._json_price(customer_price),
"customer_order_no": str(customer_order_no).strip(), "customer_order_no": str(customer_order_no).strip(),
"product_id": str(product_id).strip(), "product_id": str(product_id).strip(),
"recharge_arg": recharge_arg, }
"notify_url": str(notify_url).strip(), # 文档规定可选参数未特别约定时默认不传,不能以空值或内部字段占位。
"ext_arg": ext_arg or {}, 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
return self._request("POST", self.CREATE_ORDER_PATH, params)
def query_order(self, **query: Any) -> dict[str, Any]: def query_order(self, **query: Any) -> dict[str, Any]:
"""查询订单。 """查询订单。
+1
View File
@@ -46,6 +46,7 @@ services:
- FISH_FIN_RECHARGE_APP_KEY=${FISH_FIN_RECHARGE_APP_KEY:-} - FISH_FIN_RECHARGE_APP_KEY=${FISH_FIN_RECHARGE_APP_KEY:-}
- FISH_FIN_RECHARGE_APP_SECRET=${FISH_FIN_RECHARGE_APP_SECRET:-} - FISH_FIN_RECHARGE_APP_SECRET=${FISH_FIN_RECHARGE_APP_SECRET:-}
- FISH_FIN_RECHARGE_TIMEOUT=${FISH_FIN_RECHARGE_TIMEOUT:-20} - FISH_FIN_RECHARGE_TIMEOUT=${FISH_FIN_RECHARGE_TIMEOUT:-20}
- FISH_FIN_RECHARGE_DEBUG=${FISH_FIN_RECHARGE_DEBUG:-false}
# Roundcube 邮件验证码读取服务地址(不填则使用代码默认值) # Roundcube 邮件验证码读取服务地址(不填则使用代码默认值)
- MAIL_ROUNDCUBE_URL=${MAIL_ROUNDCUBE_URL:-} - MAIL_ROUNDCUBE_URL=${MAIL_ROUNDCUBE_URL:-}
# 生产容器禁止 reload;开发环境由 dev.sh 单独启动 reload。 # 生产容器禁止 reload;开发环境由 dev.sh 单独启动 reload。
+6
View File
@@ -18,6 +18,12 @@
嵌套的 `recharge_arg``ext_arg` 在签名中使用稳定的紧凑 JSON 字符串。该序列化方式需要供应商联调确认;若其服务端使用其他规则,应在 `FishFinRechargeClient._sign_value` 中按确认规则调整。 嵌套的 `recharge_arg``ext_arg` 在签名中使用稳定的紧凑 JSON 字符串。该序列化方式需要供应商联调确认;若其服务端使用其他规则,应在 `FishFinRechargeClient._sign_value` 中按确认规则调整。
## 已确认商品
供应商商品列表中,鱼翅商品为“鱼翅-1元”:`goodsNo=111570`、单份供货成本 `0.993`。项目将它作为 API 渠道的默认商品参数。用户在工作台填写的充值面值同时传为 `buy_num``customer_price`,例如填写 `10` 会为每个勾选账号创建 `buy_num=10``customer_price=10` 的订单;整数金额以 JSON 整数发送,例如 `1` 而不是 `1.0`。供货成本不参与下单金额。
创建订单只会发送文档示例中的必填字段和 UID 对应的 `recharge_arg`;空 `notify_url` 与未约定的 `ext_arg` 不会发送或参与签名。`ext_arg` 仅在供应商明确约定 `skuid``skuname` 等字段后才可启用。
## 接入前待供应商确认 ## 接入前待供应商确认
1. API 网关基地址(文档只给出相对路径)。 1. API 网关基地址(文档只给出相对路径)。
+108
View File
@@ -0,0 +1,108 @@
"""斗鱼鱼翅充值渠道分流测试。"""
import os
import unittest
from unittest.mock import Mock, patch
os.environ.setdefault("DATABASE_URL", "sqlite://")
os.environ.setdefault("APP_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from web.backend.database import Base
from web.backend.models import Account, DouyuTask, User
from web.backend.services.douyu_runner import DouyuBatchRunner
class DouyuGoldRechargeChannelTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite://")
Base.metadata.create_all(self.engine)
self.session = sessionmaker(bind=self.engine)()
user = User(username="operator", password_hash="hash", role="super_admin")
self.session.add(user)
self.session.commit()
self.account = Account(
username="douyu-user",
password="password",
email="mail@example.com",
email_password="mail-password",
uid="10001",
)
self.session.add(self.account)
self.session.commit()
self.task = DouyuTask(
batch_id="batch", account_id=self.account.id, task_type="create_gold_qr",
handbook_scope="elite", status="running", created_by=user.id,
)
self.session.add(self.task)
self.session.commit()
self.runner = DouyuBatchRunner(self.session, "batch", "create_gold_qr")
def tearDown(self):
self.session.close()
Base.metadata.drop_all(self.engine)
self.engine.dispose()
@patch("web.backend.services.douyu_runner.FishFinRechargeClient")
def test_supplier_channel_creates_order_using_uid_and_finishes_on_success(self, client_class):
supplier = Mock()
supplier.create_order.return_value = {
"code": 200,
"result": {"order_status": 2, "order_no": "supplier-001"},
"sign": "not-stored",
}
client_class.return_value = supplier
config = {
"gold_recharge_channel": "supplier_api",
"gold_api_product_id": "gold-product",
"gold_api_account_template_name": "斗鱼账号",
}
self.task.result = {"payload": {"amount": 10}}
self.session.commit()
self.runner._execute_create_gold_qr(
self.session, self.task, self.account, "acf_uid=10001", config,
)
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}",
product_id="gold-product",
recharge_arg=[{"templateName": "斗鱼账号", "templateVal": "10001"}],
)
self.assertEqual(str(supplier.create_order.call_args.kwargs["customer_price"]), "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.assertNotIn("pay_url", self.task.result)
self.assertNotIn("sign", self.task.result["supplier_order"])
@patch("web.backend.services.douyu_runner.FishFinRechargeClient")
def test_supplier_channel_marks_non_200_create_response_as_failed(self, client_class):
supplier = Mock()
supplier.create_order.return_value = {"code": 400, "msg": "商品已下架"}
client_class.return_value = supplier
config = {
"gold_recharge_channel": "supplier_api",
"gold_api_product_id": "gold-product",
"gold_api_account_template_name": "斗鱼UID",
}
self.runner._execute_create_gold_qr(
self.session, self.task, self.account, "acf_uid=10001", config,
)
self.session.refresh(self.task)
self.assertEqual(self.task.status, "failed")
self.assertEqual(self.task.message, "商品已下架")
if __name__ == "__main__":
unittest.main()
+65 -2
View File
@@ -34,7 +34,7 @@ class FishFinRechargeClientTests(unittest.TestCase):
}, "POST") }, "POST")
self.assertEqual(sign, "4087a959f3488ecb13efe6ef58e3bc67") self.assertEqual(sign, "4087a959f3488ecb13efe6ef58e3bc67")
def test_create_order_posts_json_with_signed_nested_arguments(self): def test_create_order_posts_json_with_signed_optional_arguments(self):
response = Mock() response = Mock()
response.json.return_value = {"code": 200, "order_status": 0} response.json.return_value = {"code": 200, "order_status": 0}
self.session.post.return_value = response self.session.post.return_value = response
@@ -52,10 +52,27 @@ class FishFinRechargeClientTests(unittest.TestCase):
self.assertEqual(result["code"], 200) self.assertEqual(result["code"], 200)
kwargs = self.session.post.call_args.kwargs 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(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"]["customer_price"], 1.2)
self.assertEqual(kwargs["json"]["sign"], self.client.sign(kwargs["json"], "POST")) self.assertEqual(kwargs["json"]["sign"], self.client.sign(kwargs["json"], "POST"))
self.assertEqual(kwargs["timeout"], (8, 20)) self.assertEqual(kwargs["timeout"], (8, 20))
def test_create_order_omits_empty_optional_fields_and_serializes_integer_price(self):
response = Mock()
response.json.return_value = {"code": 200}
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",
)
body = self.session.post.call_args.kwargs["json"]
self.assertEqual(body["customer_price"], 1)
self.assertNotIn("notify_url", body)
self.assertNotIn("recharge_arg", body)
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): def test_query_order_requires_identifier_and_uses_get_params(self):
with self.assertRaisesRegex(ValueError, "订单标识"): with self.assertRaisesRegex(ValueError, "订单标识"):
self.client.query_order() self.client.query_order()
@@ -74,6 +91,52 @@ class FishFinRechargeClientTests(unittest.TestCase):
with self.assertRaises(FishFinRechargeConfigError): with self.assertRaises(FishFinRechargeConfigError):
FishFinRechargeClient(FishFinRechargeConfig(base_url="", app_id="", app_secret="")) FishFinRechargeClient(FishFinRechargeConfig(base_url="", app_id="", app_secret=""))
def test_trace_excludes_replayable_signature_and_nested_account_data(self):
response = Mock()
response.status_code = 200
response.json.return_value = {"code": 200, "result": {"order_status": 0}}
self.session.post.return_value = response
events = []
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",
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)
def test_debug_trace_includes_complete_request_and_response_bodies(self):
response = Mock()
response.status_code = 200
response.headers = {"Content-Type": "application/json", "X-Request-Id": "request-1"}
response.text = '{"code":1000,"message":"未传递支付金额","sign":"response-sign"}'
response.json.return_value = {"code": 1000, "message": "未传递支付金额", "sign": "response-sign"}
self.session.post.return_value = response
events = []
config = FishFinRechargeConfig(
base_url=self.config.base_url, app_id=self.config.app_id,
app_secret=self.config.app_secret, debug=True,
)
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",
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.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")
self.assertEqual(events[1]["response_text"], response.text)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -0,0 +1,46 @@
"""增加斗鱼鱼翅充值渠道配置
Revision ID: 20260813_0026
Revises: 20260813_0025
Create Date: 2026-08-13
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260813_0026"
down_revision: Union[str, None] = "20260813_0025"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _columns(bind) -> set[str]:
return {column["name"] for column in sa.inspect(bind).get_columns("douyu_config")}
def upgrade() -> None:
bind = op.get_bind()
if not sa.inspect(bind).has_table("douyu_config"):
return
columns = _columns(bind)
additions = (
("gold_recharge_channel", sa.Column("gold_recharge_channel", sa.String(length=16), nullable=True, server_default="wechat_qr")),
("gold_api_product_id", sa.Column("gold_api_product_id", sa.String(length=128), nullable=True, server_default="111570")),
("gold_api_account_template_name", sa.Column("gold_api_account_template_name", sa.String(length=64), nullable=True, server_default="斗鱼UID")),
)
for name, column in additions:
if name not in columns:
op.add_column("douyu_config", column)
def downgrade() -> None:
bind = op.get_bind()
if not sa.inspect(bind).has_table("douyu_config"):
return
columns = _columns(bind)
for name in ("gold_api_account_template_name", "gold_api_product_id", "gold_recharge_channel"):
if name in columns:
op.drop_column("douyu_config", name)
+3
View File
@@ -214,6 +214,9 @@ class DouyuConfig(Base):
xpd_act_id = Column(String(64), default="46195") xpd_act_id = Column(String(64), default="46195")
xpd_rid = Column(String(64), default="9263298") xpd_rid = Column(String(64), default="9263298")
gold_pay_type = Column(Integer, default=1) gold_pay_type = Column(Integer, default=1)
gold_recharge_channel = Column(String(16), default="wechat_qr")
gold_api_product_id = Column(String(128), default="111570")
gold_api_account_template_name = Column(String(64), default="斗鱼UID")
gift_id = Column(String(64), default="23643") gift_id = Column(String(64), default="23643")
skin_id = Column(String(64), default="2942") skin_id = Column(String(64), default="2942")
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
+3
View File
@@ -154,6 +154,9 @@ def _config_out(config: DouyuConfig) -> DouyuConfigOut:
xpd_act_id=douyu_config_value("xpd_act_id", config.xpd_act_id), xpd_act_id=douyu_config_value("xpd_act_id", config.xpd_act_id),
xpd_rid=douyu_config_value("xpd_rid", config.xpd_rid), xpd_rid=douyu_config_value("xpd_rid", config.xpd_rid),
gold_pay_type=douyu_config_value("gold_pay_type", config.gold_pay_type), gold_pay_type=douyu_config_value("gold_pay_type", config.gold_pay_type),
gold_recharge_channel=douyu_config_value("gold_recharge_channel", config.gold_recharge_channel),
gold_api_product_id=douyu_config_value("gold_api_product_id", config.gold_api_product_id),
gold_api_account_template_name=douyu_config_value("gold_api_account_template_name", config.gold_api_account_template_name),
gift_id=douyu_config_value("gift_id", config.gift_id), gift_id=douyu_config_value("gift_id", config.gift_id),
skin_id=douyu_config_value("skin_id", config.skin_id), skin_id=douyu_config_value("skin_id", config.skin_id),
updated_at=config.updated_at, updated_at=config.updated_at,
+9
View File
@@ -585,6 +585,9 @@ class DouyuConfigOut(BaseModel):
xpd_act_id: str = "46195" xpd_act_id: str = "46195"
xpd_rid: str = "9263298" xpd_rid: str = "9263298"
gold_pay_type: int = 1 gold_pay_type: int = 1
gold_recharge_channel: str = "wechat_qr"
gold_api_product_id: str = "111570"
gold_api_account_template_name: str = "斗鱼UID"
gift_id: str = "23643" gift_id: str = "23643"
skin_id: str = "2942" skin_id: str = "2942"
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
@@ -610,6 +613,9 @@ class DouyuConfigOut(BaseModel):
"xpd_act_id": self.xpd_act_id, "xpd_act_id": self.xpd_act_id,
"xpd_rid": self.xpd_rid, "xpd_rid": self.xpd_rid,
"gold_pay_type": self.gold_pay_type, "gold_pay_type": self.gold_pay_type,
"gold_recharge_channel": self.gold_recharge_channel,
"gold_api_product_id": self.gold_api_product_id,
"gold_api_account_template_name": self.gold_api_account_template_name,
"gift_id": self.gift_id, "gift_id": self.gift_id,
"skin_id": self.skin_id, "skin_id": self.skin_id,
"updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None, "updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None,
@@ -635,6 +641,9 @@ class DouyuConfigUpdate(BaseModel):
xpd_act_id: Optional[str] = None xpd_act_id: Optional[str] = None
xpd_rid: Optional[str] = None xpd_rid: Optional[str] = None
gold_pay_type: Optional[int] = Field(None, ge=1, le=9) gold_pay_type: Optional[int] = Field(None, ge=1, le=9)
gold_recharge_channel: Optional[str] = Field(None, pattern="^(wechat_qr|supplier_api)$")
gold_api_product_id: Optional[str] = Field(None, max_length=128)
gold_api_account_template_name: Optional[str] = Field(None, max_length=64)
gift_id: Optional[str] = None gift_id: Optional[str] = None
skin_id: Optional[str] = None skin_id: Optional[str] = None
+199 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import threading import threading
import time import time
from decimal import Decimal
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
@@ -12,7 +13,13 @@ from typing import Optional
from loguru import logger from loguru import logger
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from core.douyu import DouyuActivityClient, DouyuActivityError from core.douyu import (
DouyuActivityClient,
DouyuActivityError,
FishFinRechargeClient,
FishFinRechargeConfig,
FishFinRechargeError,
)
from ..database import SessionLocal from ..database import SessionLocal
from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuXpdGoodsSnapshot from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuXpdGoodsSnapshot
@@ -719,6 +726,87 @@ class DouyuBatchRunner:
result["gold_balance"] = last_gold result["gold_balance"] = last_gold
return False return False
@staticmethod
def _supplier_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
@classmethod
def _supplier_order_status(cls, payload: dict) -> int | None:
"""提取供应商订单状态,文档约定 0-4。"""
return cls._to_int(cls._supplier_value(payload, "order_status", "orderStatus"))
@classmethod
def _supplier_message(cls, payload: dict) -> str:
"""提取供应商可展示的业务消息。"""
value = cls._supplier_value(payload, "msg", "message", "error_msg")
return str(value or "")[:256]
@staticmethod
def _supplier_result(payload: dict) -> dict:
"""保存必要订单状态,避免把完整供应商响应或签名暴露到任务结果。"""
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
response_result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
result = {
key: value
for key, value in {**payload, **data, **response_result}.items()
if key not in {"sign", "cards", "card_no", "card_pwd", "recharge_arg"}
}
return result
def _wait_supplier_gold_order(
self,
db: Session,
task: DouyuTask,
client: FishFinRechargeClient,
result: dict,
) -> int | None:
"""轮询供应商直充订单至结束状态。"""
order_no = str(result["customer_order_no"])
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)
code = self._to_int(self._supplier_value(payload, "code"))
status = self._supplier_order_status(payload)
poll_count += 1
result.update({
"payment_poll_count": poll_count,
"supplier_code": code,
"supplier_order_status": status,
"supplier_order": self._supplier_result(payload),
})
if code != 200:
result["payment_polling"] = False
return status if status in {2, 3, 4} else 4
if status in {2, 3, 4}:
result["payment_polling"] = False
return status
self._update_task_progress(
db,
task,
"running",
f"供应商直充订单处理中(状态 {status if status is not None else '-'}",
result,
)
except FishFinRechargeError as exc:
poll_count += 1
result["payment_poll_count"] = poll_count
result["payment_poll_error"] = str(exc)
self._update_task_progress(db, task, "running", f"查询供应商订单失败: {exc}", result)
if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL):
break
result["payment_polling"] = False
return None
def _refresh_points_after_elite_gift( def _refresh_points_after_elite_gift(
self, self,
db: Session, db: Session,
@@ -2358,6 +2446,13 @@ class DouyuBatchRunner:
def _execute_create_gold_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): def _execute_create_gold_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
payload = self._task_payload(task) payload = self._task_payload(task)
amount = int(payload.get("amount") or payload.get("gold_amount") or 1) amount = int(payload.get("amount") or payload.get("gold_amount") or 1)
channel = str(config.get("gold_recharge_channel") or "wechat_qr")
if channel == "supplier_api":
try:
self._execute_create_gold_supplier_order(db, task, account, config, amount)
except FishFinRechargeError as exc:
self._mark_task(db, task, "failed", str(exc), {"recharge_channel": "supplier_api"})
return
client = self._client(cookie) client = self._client(cookie)
baseline_gold = account.gold_balance baseline_gold = account.gold_balance
try: try:
@@ -2393,6 +2488,109 @@ class DouyuBatchRunner:
result, result,
) )
def _execute_create_gold_supplier_order(
self,
db: Session,
task: DouyuTask,
account: Account,
config: dict,
amount: int,
) -> None:
"""创建供应商鱼翅直充订单并轮询订单状态。"""
product_id = str(config.get("gold_api_product_id") or "").strip()
template_name = str(config.get("gold_api_account_template_name") or "斗鱼UID").strip()
if not product_id:
raise FishFinRechargeError("请先在配置中填写供应商直充商品 ID")
charge_account = str(account.uid or "").strip()
if not charge_account:
raise FishFinRechargeError("账号缺少斗鱼 UID,无法发起供应商直充")
# task.id 是唯一且稳定的商户单号来源,重试时不会创建不同的供应商订单。
order_no = f"DYGF{task.id}"
# customer_price 是用户选择的充值面值;goodsFaceValue=0.993 是供货成本,不能作为支付金额。
customer_price = Decimal(amount)
def trace(event: dict) -> None:
"""将脱敏供应商协议信息输出到任务日志,便于线上联调。"""
stage = event.get("stage")
if stage == "request":
params = event.get("params") or {}
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"product_id={params.get('product_id')} types={event.get('parameter_types')} "
f"sign_digest={event.get('sign_digest')}",
)
if event.get("json_body"):
self._push_log(
"info",
"供应商直充完整调试请求 "
f"url={event.get('url')} content_type={event.get('content_type')} "
f"body={FishFinRechargeClient._json_text(event['json_body'])} "
f"sign_params={FishFinRechargeClient._json_text(event.get('sign_params') or {})} "
f"sign_source={event.get('sign_source')}",
)
elif stage == "response":
self._push_log(
"info",
"供应商直充响应 "
f"http={event.get('http_status')} code={event.get('code')} "
f"message={event.get('message') or '-'} keys={event.get('keys')} "
f"data_keys={event.get('data_keys')} result_keys={event.get('result_keys')}",
)
if event.get("response_body"):
self._push_log(
"info",
"供应商直充完整调试响应 "
f"headers={FishFinRechargeClient._json_text(event.get('response_headers') or {})} "
f"body={FishFinRechargeClient._json_text(event['response_body'])} "
f"raw={event.get('response_text')}",
)
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,
product_id=product_id,
recharge_arg=[{"templateName": template_name, "templateVal": charge_account}],
)
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,
"charge_account": charge_account,
"buy_num": amount,
"product_id": product_id,
"customer_price": format(customer_price.normalize(), "f"),
"supplier_code": code,
"supplier_order_status": status,
"supplier_order": self._supplier_result(order_payload),
}
if code != 200:
self._mark_task(db, task, "failed", self._supplier_message(order_payload) or "供应商创建直充订单失败", result)
return
account.bind_status = "gold_api_order_created"
account.updated_at = datetime.now(timezone.utc)
self._update_task_progress(db, task, "running", "供应商直充订单已创建,等待到账", result)
if status not in {2, 3, 4}:
status = self._wait_supplier_gold_order(db, task, client, result)
if self._stop.is_set():
self._mark_task(db, task, "stopped", "任务已停止", result)
return
if status == 2:
account.bind_status = "gold_recharged"
account.updated_at = datetime.now(timezone.utc)
self._mark_task(db, task, "success", "供应商直充成功", result)
return
if status in {3, 4}:
self._mark_task(db, task, "failed", "供应商直充失败", result)
return
self._mark_task(db, task, "failed", "供应商直充订单查询超时", result)
def _execute_donate_elite_gift(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): def _execute_donate_elite_gift(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
payload = self._task_payload(task) payload = self._task_payload(task)
gift_count = int(payload.get("gift_count") or payload.get("count") or 1) gift_count = int(payload.get("gift_count") or payload.get("count") or 1)
+5
View File
@@ -85,6 +85,9 @@ DOUYU_CONFIG_DEFAULTS = { "manual_id": "G4KA4Qnz4LDp7",
"esports_firework_gift_id": "24767", "esports_firework_gift_id": "24767",
"esports_firework_skin_id": "3850", "esports_firework_skin_id": "3850",
"gold_pay_type": 1, "gold_pay_type": 1,
"gold_recharge_channel": "wechat_qr",
"gold_api_product_id": "111570",
"gold_api_account_template_name": "斗鱼UID",
"gift_id": "23643", "gift_id": "23643",
"skin_id": "2942", "skin_id": "2942",
"xpd_act_alias": "20260623KDQFH", "xpd_act_alias": "20260623KDQFH",
@@ -117,6 +120,8 @@ def apply_douyu_config_defaults(config: DouyuConfig) -> bool:
changed = True changed = True
continue continue
normalized = douyu_config_value(field, getattr(config, field, None)) normalized = douyu_config_value(field, getattr(config, field, None))
if field == "gold_recharge_channel" and normalized not in {"wechat_qr", "supplier_api"}:
normalized = DOUYU_CONFIG_DEFAULTS[field]
if getattr(config, field, None) != normalized: if getattr(config, field, None) != normalized:
setattr(config, field, normalized) setattr(config, field, normalized)
changed = True changed = True
+3
View File
@@ -354,6 +354,9 @@ export interface DouyuConfig {
xpd_act_id: string; xpd_act_id: string;
xpd_rid: string; xpd_rid: string;
gold_pay_type: number; gold_pay_type: number;
gold_recharge_channel: 'wechat_qr' | 'supplier_api';
gold_api_product_id: string;
gold_api_account_template_name: string;
gift_id: string; gift_id: string;
skin_id: string; skin_id: string;
updated_at: string | null; updated_at: string | null;
+49 -2
View File
@@ -1626,6 +1626,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
}, [configOpen, config]); }, [configOpen, config]);
const payUrl = resultText(payTask?.result, 'pay_url'); const payUrl = resultText(payTask?.result, 'pay_url');
const goldAmountLabel = '充值金额';
const isSupplierGoldRecharge = config?.gold_recharge_channel === 'supplier_api';
const goldRechargeChannelLabel = isSupplierGoldRecharge ? '供应商 API 直充' : '微信扫码';
const goldRechargeButtonLabel = isSupplierGoldRecharge ? 'API直充鱼翅' : '扫码充值鱼翅';
const payAccountName = payTask const payAccountName = payTask
? (payTask.account_nickname || payTask.account_username || payTask.account_uid || `#${payTask.account_id}`) ? (payTask.account_nickname || payTask.account_username || payTask.account_uid || `#${payTask.account_id}`)
: ''; : '';
@@ -1785,12 +1789,14 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
<span></span> <span></span>
</div> </div>
<Space direction="vertical" style={{ width: '100%' }} size={6}> <Space direction="vertical" style={{ width: '100%' }} size={6}>
<Tag color={isSupplierGoldRecharge ? 'green' : 'blue'}>{`当前渠道:${goldRechargeChannelLabel}`}</Tag>
<Space.Compact style={{ width: '100%' }}> <Space.Compact style={{ width: '100%' }}>
<InputNumber <InputNumber
size="small" size="small"
min={1} min={1}
value={goldAmount} value={goldAmount}
onChange={(v) => setGoldAmount(v || 1)} onChange={(v) => setGoldAmount(v || 1)}
addonBefore={goldAmountLabel}
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
<Button <Button
@@ -1799,7 +1805,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
onClick={() => startTask('create_gold_qr')} onClick={() => startTask('create_gold_qr')}
disabled={actionDisabled('create_gold_qr')} disabled={actionDisabled('create_gold_qr')}
> >
{goldRechargeButtonLabel}
</Button> </Button>
</Space.Compact> </Space.Compact>
<InputNumber <InputNumber
@@ -1849,12 +1855,14 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
</div> </div>
<Space direction="vertical" style={{ width: '100%' }} size={6}> <Space direction="vertical" style={{ width: '100%' }} size={6}>
{renderActionButton('create_elite_qr', 'primary')} {renderActionButton('create_elite_qr', 'primary')}
<Tag color={isSupplierGoldRecharge ? 'green' : 'blue'}>{`当前渠道:${goldRechargeChannelLabel}`}</Tag>
<Space.Compact style={{ width: '100%' }}> <Space.Compact style={{ width: '100%' }}>
<InputNumber <InputNumber
size="small" size="small"
min={1} min={1}
value={goldAmount} value={goldAmount}
onChange={(v) => setGoldAmount(v || 1)} onChange={(v) => setGoldAmount(v || 1)}
addonBefore={goldAmountLabel}
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
<Button <Button
@@ -1863,7 +1871,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
onClick={() => startTask('create_gold_qr')} onClick={() => startTask('create_gold_qr')}
disabled={actionDisabled('create_gold_qr')} disabled={actionDisabled('create_gold_qr')}
> >
{goldRechargeButtonLabel}
</Button> </Button>
</Space.Compact> </Space.Compact>
<Space.Compact style={{ width: '100%' }}> <Space.Compact style={{ width: '100%' }}>
@@ -2198,6 +2206,45 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
> >
{configFormValues && ( {configFormValues && (
<Space direction="vertical" style={{ width: '100%' }} size={8}> <Space direction="vertical" style={{ width: '100%' }} size={8}>
{!isPeaceHandbook && (
<>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Text></Text>
<Select
style={{ width: 300 }}
value={configFormValues.gold_recharge_channel}
options={[
{ value: 'wechat_qr', label: '微信扫码' },
{ value: 'supplier_api', label: '供应商 API 直充' },
]}
onChange={(value) => setConfigFormValues((prev) => prev ? {
...prev,
gold_recharge_channel: value,
} : prev)}
/>
</Space>
{configFormValues.gold_recharge_channel === 'supplier_api' && (
<>
{[
{ label: '供应商商品 ID', key: 'gold_api_product_id' },
{ label: '充值账号模板名', key: 'gold_api_account_template_name' },
].map(({ label, key }) => (
<Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}>
<Text>{label}</Text>
<Input
style={{ width: 300 }}
value={String(configFormValues[key as keyof DouyuConfig] ?? '')}
onChange={(e) => setConfigFormValues((prev) => prev ? {
...prev,
[key]: e.target.value,
} : prev)}
/>
</Space>
))}
</>
)}
</>
)}
{(isPeaceHandbook ? [ {(isPeaceHandbook ? [
{ label: '小店活动代号', key: 'xpd_act_alias' }, { label: '小店活动代号', key: 'xpd_act_alias' },
{ label: '道聚城活动 ID', key: 'xpd_act_id' }, { label: '道聚城活动 ID', key: 'xpd_act_id' },