diff --git a/.env.example b/.env.example index b5350d7..1f7cdec 100644 --- a/.env.example +++ b/.env.example @@ -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/ diff --git a/core/douyu/recharge_api.py b/core/douyu/recharge_api.py index bb7c988..c8b7f9d 100644 --- a/core/douyu/recharge_api.py +++ b/core/douyu/recharge_api.py @@ -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]: """查询订单。 diff --git a/docker-compose.yml b/docker-compose.yml index 30c001c..4d16eeb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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。 diff --git a/docs/鱼翅充值api/接入分析.md b/docs/鱼翅充值api/接入分析.md index 7516485..a662e16 100644 --- a/docs/鱼翅充值api/接入分析.md +++ b/docs/鱼翅充值api/接入分析.md @@ -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 网关基地址(文档只给出相对路径)。 diff --git a/tests/test_douyu_gold_recharge_channel.py b/tests/test_douyu_gold_recharge_channel.py new file mode 100644 index 0000000..9c17aa2 --- /dev/null +++ b/tests/test_douyu_gold_recharge_channel.py @@ -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() diff --git a/tests/test_fish_fin_recharge_api.py b/tests/test_fish_fin_recharge_api.py index e839f24..11ec988 100644 --- a/tests/test_fish_fin_recharge_api.py +++ b/tests/test_fish_fin_recharge_api.py @@ -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() diff --git a/web/backend/migrations/versions/20260813_0026_douyu_gold_recharge_channel.py b/web/backend/migrations/versions/20260813_0026_douyu_gold_recharge_channel.py new file mode 100644 index 0000000..21c2a13 --- /dev/null +++ b/web/backend/migrations/versions/20260813_0026_douyu_gold_recharge_channel.py @@ -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) diff --git a/web/backend/models.py b/web/backend/models.py index 6c6bf6c..e015ce2 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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) diff --git a/web/backend/routers/douyu.py b/web/backend/routers/douyu.py index 265da1e..3d6108d 100644 --- a/web/backend/routers/douyu.py +++ b/web/backend/routers/douyu.py @@ -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, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index ab6b8c7..615765e 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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 diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index 45f913c..8507a48 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -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) diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index debe187..345dd3a 100644 --- a/web/backend/services/douyu_service.py +++ b/web/backend/services/douyu_service.py @@ -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 diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 1a7aac3..f8bd15a 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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; diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index 57c7b13..f88e70e 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -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 }) 充值与送礼 + {`当前渠道:${goldRechargeChannelLabel}`} setGoldAmount(v || 1)} + addonBefore={goldAmountLabel} style={{ width: '100%' }} /> {renderActionButton('create_elite_qr', 'primary')} + {`当前渠道:${goldRechargeChannelLabel}`} setGoldAmount(v || 1)} + addonBefore={goldAmountLabel} style={{ width: '100%' }} /> @@ -2198,6 +2206,45 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) > {configFormValues && ( + {!isPeaceHandbook && ( + <> + + 鱼翅充值渠道 + setConfigFormValues((prev) => prev ? { + ...prev, + [key]: e.target.value, + } : prev)} + /> + + ))} + + )} + + )} {(isPeaceHandbook ? [ { label: '小店活动代号', key: 'xpd_act_alias' }, { label: '道聚城活动 ID', key: 'xpd_act_id' },