功能: 接入鱼翅供应商直充渠道
This commit is contained in:
@@ -77,6 +77,8 @@ FISH_FIN_RECHARGE_APP_ID=
|
||||
FISH_FIN_RECHARGE_APP_KEY=
|
||||
FISH_FIN_RECHARGE_APP_SECRET=
|
||||
# FISH_FIN_RECHARGE_TIMEOUT=20
|
||||
# 排查供应商协议时临时开启;会输出完整请求、签名、AppSecret 和响应,排查后必须关闭。
|
||||
FISH_FIN_RECHARGE_DEBUG=false
|
||||
|
||||
# Roundcube 邮件验证码读取服务地址(可选;不填则使用默认服务)
|
||||
# MAIL_ROUNDCUBE_URL=http://127.0.0.1:8000/
|
||||
|
||||
+87
-10
@@ -8,7 +8,7 @@ import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Mapping
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
import requests
|
||||
|
||||
@@ -30,6 +30,7 @@ class FishFinRechargeConfig:
|
||||
app_secret: str
|
||||
app_key: str = ""
|
||||
timeout: tuple[float, float] = (8, 20)
|
||||
debug: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "FishFinRechargeConfig":
|
||||
@@ -41,6 +42,7 @@ class FishFinRechargeConfig:
|
||||
app_secret=os.getenv("FISH_FIN_RECHARGE_APP_SECRET", "").strip(),
|
||||
app_key=os.getenv("FISH_FIN_RECHARGE_APP_KEY", "").strip(),
|
||||
timeout=(8, timeout),
|
||||
debug=os.getenv("FISH_FIN_RECHARGE_DEBUG", "false").strip().lower() in {"1", "true", "yes"},
|
||||
)
|
||||
|
||||
def validate(self) -> None:
|
||||
@@ -65,11 +67,18 @@ class FishFinRechargeClient:
|
||||
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):
|
||||
def __init__(
|
||||
self,
|
||||
config: FishFinRechargeConfig,
|
||||
*,
|
||||
session: requests.Session | None = None,
|
||||
trace: Callable[[dict[str, Any]], None] | None = None,
|
||||
):
|
||||
config.validate()
|
||||
self.config = config
|
||||
self.session = session or requests.Session()
|
||||
self.session.trust_env = False
|
||||
self.trace = trace or (lambda _event: None)
|
||||
|
||||
@staticmethod
|
||||
def _sign_value(value: Any) -> str:
|
||||
@@ -100,6 +109,11 @@ class FishFinRechargeClient:
|
||||
raw = f"{query}{method.upper()}{self.config.app_secret}"
|
||||
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:
|
||||
"""校验带 sign 的响应;供应商未返回 sign 时由调用方决定是否接受。"""
|
||||
received = str(payload.get("sign") or "").strip().lower()
|
||||
@@ -114,6 +128,35 @@ class FishFinRechargeClient:
|
||||
}
|
||||
request_params["sign"] = self.sign(request_params, method)
|
||||
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:
|
||||
if method.upper() == "GET":
|
||||
response = self.session.get(url, params=request_params, timeout=self.config.timeout)
|
||||
@@ -127,6 +170,28 @@ class FishFinRechargeClient:
|
||||
raise FishFinRechargeError("供应商响应不是 JSON") from exc
|
||||
if not isinstance(payload, dict):
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
@@ -140,6 +205,12 @@ class FishFinRechargeClient:
|
||||
raise ValueError("customer_price 必须大于 0")
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
@@ -148,7 +219,7 @@ class FishFinRechargeClient:
|
||||
customer_price: Decimal | int | float | str,
|
||||
customer_order_no: str,
|
||||
product_id: str,
|
||||
recharge_arg: list[dict[str, Any]],
|
||||
recharge_arg: list[dict[str, Any]] | None = None,
|
||||
notify_url: str = "",
|
||||
ext_arg: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
@@ -161,18 +232,24 @@ class FishFinRechargeClient:
|
||||
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:
|
||||
if recharge_arg is not None and (not isinstance(recharge_arg, list) or not recharge_arg):
|
||||
raise ValueError("recharge_arg 必须是非空数组")
|
||||
return self._request("POST", self.CREATE_ORDER_PATH, {
|
||||
params: dict[str, Any] = {
|
||||
"charge_account": str(charge_account).strip(),
|
||||
"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(),
|
||||
"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]:
|
||||
"""查询订单。
|
||||
|
||||
@@ -46,6 +46,7 @@ services:
|
||||
- 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}
|
||||
- FISH_FIN_RECHARGE_DEBUG=${FISH_FIN_RECHARGE_DEBUG:-false}
|
||||
# Roundcube 邮件验证码读取服务地址(不填则使用代码默认值)
|
||||
- MAIL_ROUNDCUBE_URL=${MAIL_ROUNDCUBE_URL:-}
|
||||
# 生产容器禁止 reload;开发环境由 dev.sh 单独启动 reload。
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
|
||||
嵌套的 `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 网关基地址(文档只给出相对路径)。
|
||||
|
||||
@@ -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()
|
||||
@@ -34,7 +34,7 @@ class FishFinRechargeClientTests(unittest.TestCase):
|
||||
}, "POST")
|
||||
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.json.return_value = {"code": 200, "order_status": 0}
|
||||
self.session.post.return_value = response
|
||||
@@ -52,10 +52,27 @@ 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"]["customer_price"], 1.2)
|
||||
self.assertEqual(kwargs["json"]["sign"], self.client.sign(kwargs["json"], "POST"))
|
||||
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):
|
||||
with self.assertRaisesRegex(ValueError, "订单标识"):
|
||||
self.client.query_order()
|
||||
@@ -74,6 +91,52 @@ class FishFinRechargeClientTests(unittest.TestCase):
|
||||
with self.assertRaises(FishFinRechargeConfigError):
|
||||
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__":
|
||||
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)
|
||||
@@ -214,6 +214,9 @@ class DouyuConfig(Base):
|
||||
xpd_act_id = Column(String(64), default="46195")
|
||||
xpd_rid = Column(String(64), default="9263298")
|
||||
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")
|
||||
skin_id = Column(String(64), default="2942")
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
@@ -154,6 +154,9 @@ def _config_out(config: DouyuConfig) -> DouyuConfigOut:
|
||||
xpd_act_id=douyu_config_value("xpd_act_id", config.xpd_act_id),
|
||||
xpd_rid=douyu_config_value("xpd_rid", config.xpd_rid),
|
||||
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),
|
||||
skin_id=douyu_config_value("skin_id", config.skin_id),
|
||||
updated_at=config.updated_at,
|
||||
|
||||
@@ -585,6 +585,9 @@ class DouyuConfigOut(BaseModel):
|
||||
xpd_act_id: str = "46195"
|
||||
xpd_rid: str = "9263298"
|
||||
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"
|
||||
skin_id: str = "2942"
|
||||
updated_at: Optional[datetime] = None
|
||||
@@ -610,6 +613,9 @@ class DouyuConfigOut(BaseModel):
|
||||
"xpd_act_id": self.xpd_act_id,
|
||||
"xpd_rid": self.xpd_rid,
|
||||
"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,
|
||||
"skin_id": self.skin_id,
|
||||
"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_rid: Optional[str] = None
|
||||
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
|
||||
skin_id: Optional[str] = None
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
@@ -12,7 +13,13 @@ from typing import Optional
|
||||
from loguru import logger
|
||||
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 ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuXpdGoodsSnapshot
|
||||
@@ -719,6 +726,87 @@ class DouyuBatchRunner:
|
||||
result["gold_balance"] = last_gold
|
||||
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(
|
||||
self,
|
||||
db: Session,
|
||||
@@ -2358,6 +2446,13 @@ class DouyuBatchRunner:
|
||||
def _execute_create_gold_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
||||
payload = self._task_payload(task)
|
||||
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)
|
||||
baseline_gold = account.gold_balance
|
||||
try:
|
||||
@@ -2393,6 +2488,109 @@ class DouyuBatchRunner:
|
||||
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):
|
||||
payload = self._task_payload(task)
|
||||
gift_count = int(payload.get("gift_count") or payload.get("count") or 1)
|
||||
|
||||
@@ -85,6 +85,9 @@ DOUYU_CONFIG_DEFAULTS = { "manual_id": "G4KA4Qnz4LDp7",
|
||||
"esports_firework_gift_id": "24767",
|
||||
"esports_firework_skin_id": "3850",
|
||||
"gold_pay_type": 1,
|
||||
"gold_recharge_channel": "wechat_qr",
|
||||
"gold_api_product_id": "111570",
|
||||
"gold_api_account_template_name": "斗鱼UID",
|
||||
"gift_id": "23643",
|
||||
"skin_id": "2942",
|
||||
"xpd_act_alias": "20260623KDQFH",
|
||||
@@ -117,6 +120,8 @@ def apply_douyu_config_defaults(config: DouyuConfig) -> bool:
|
||||
changed = True
|
||||
continue
|
||||
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:
|
||||
setattr(config, field, normalized)
|
||||
changed = True
|
||||
|
||||
@@ -354,6 +354,9 @@ export interface DouyuConfig {
|
||||
xpd_act_id: string;
|
||||
xpd_rid: string;
|
||||
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;
|
||||
skin_id: string;
|
||||
updated_at: string | null;
|
||||
|
||||
@@ -1626,6 +1626,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
}, [configOpen, config]);
|
||||
|
||||
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
|
||||
? (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>
|
||||
</div>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={6}>
|
||||
<Tag color={isSupplierGoldRecharge ? 'green' : 'blue'}>{`当前渠道:${goldRechargeChannelLabel}`}</Tag>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
value={goldAmount}
|
||||
onChange={(v) => setGoldAmount(v || 1)}
|
||||
addonBefore={goldAmountLabel}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Button
|
||||
@@ -1799,7 +1805,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
onClick={() => startTask('create_gold_qr')}
|
||||
disabled={actionDisabled('create_gold_qr')}
|
||||
>
|
||||
充值鱼翅
|
||||
{goldRechargeButtonLabel}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<InputNumber
|
||||
@@ -1849,12 +1855,14 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
</div>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={6}>
|
||||
{renderActionButton('create_elite_qr', 'primary')}
|
||||
<Tag color={isSupplierGoldRecharge ? 'green' : 'blue'}>{`当前渠道:${goldRechargeChannelLabel}`}</Tag>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
value={goldAmount}
|
||||
onChange={(v) => setGoldAmount(v || 1)}
|
||||
addonBefore={goldAmountLabel}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Button
|
||||
@@ -1863,7 +1871,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
onClick={() => startTask('create_gold_qr')}
|
||||
disabled={actionDisabled('create_gold_qr')}
|
||||
>
|
||||
充值鱼翅
|
||||
{goldRechargeButtonLabel}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
@@ -2198,6 +2206,45 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
>
|
||||
{configFormValues && (
|
||||
<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 ? [
|
||||
{ label: '小店活动代号', key: 'xpd_act_alias' },
|
||||
{ label: '道聚城活动 ID', key: 'xpd_act_id' },
|
||||
|
||||
Reference in New Issue
Block a user