From e2a45b7893c3debc269f5b9769b858fb0a7cf80f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 14 Aug 2026 11:27:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=96=97=E9=B1=BC=E5=85=85?= =?UTF-8?q?=E5=80=BC=E6=97=A5=E5=BF=97=E4=B8=8E=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 +- core/douyu/recharge_api.py | 40 ++++++++-- docker-compose.yml | 2 + tests/test_douyu_gold_recharge_channel.py | 40 ++++++++-- tests/test_fish_fin_recharge_api.py | 9 ++- utils/logger.py | 10 +++ web/backend/main.py | 2 +- .../20260814_0028_douyu_nickname_recharge.py | 51 ++++++++++++ web/backend/models.py | 2 +- web/backend/schemas.py | 2 +- web/backend/services/douyu_runner.py | 78 ++++++++++++------- web/backend/services/douyu_service.py | 2 +- web/frontend/src/pages/DouyuTasksPage.tsx | 2 +- 13 files changed, 193 insertions(+), 52 deletions(-) create mode 100644 web/backend/migrations/versions/20260814_0028_douyu_nickname_recharge.py diff --git a/.env.example b/.env.example index 7db5b12..e2709ce 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,9 @@ ADMIN_PASSWORD=admin123 # Docker 对外端口(容器内仍监听 8800;服务器当前使用 8000) APP_PORT=8000 +# 应用日志等级:DEBUG、INFO、WARNING、ERROR、CRITICAL;生产环境建议保持 INFO。 +LOG_LEVEL=INFO + # Docker 构建镜像源(网络可访问官方源时保持 false;国内服务器可改为 true) USE_CHINA_MIRRORS=false @@ -79,7 +82,7 @@ FISH_FIN_RECHARGE_APP_SECRET= # 公网回调地址;不填则只通过 queryOrderV2 轮询订单状态。 FISH_FIN_RECHARGE_NOTIFY_URL= # FISH_FIN_RECHARGE_TIMEOUT=20 -# 排查供应商协议时临时开启;会输出完整请求、签名、AppSecret 和响应,排查后必须关闭。 +# 排查供应商协议时临时开启;协议参数会按 DEBUG 级别脱敏输出,排查后建议关闭。 FISH_FIN_RECHARGE_DEBUG=false # Roundcube 邮件验证码读取服务地址(可选;不填则使用默认服务) diff --git a/core/douyu/recharge_api.py b/core/douyu/recharge_api.py index 1b5ce81..d30798a 100644 --- a/core/douyu/recharge_api.py +++ b/core/douyu/recharge_api.py @@ -116,6 +116,28 @@ class FishFinRechargeClient: """生成单行 JSON 调试文本,避免日志被控制字符截断。""" return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str) + @classmethod + def _debug_payload(cls, payload: Mapping[str, Any]) -> dict[str, Any]: + """调试记录只保留协议字段,签名值和密钥相关内容统一脱敏。""" + safe = dict(payload) + if "sign" in safe: + safe["sign"] = "" + return safe + + @staticmethod + def _response_value(payload: Mapping[str, Any], *keys: str) -> Any: + """兼容供应商将订单字段放在根节点、data 或 result 节点。""" + sources = [payload] + for key in ("data", "result"): + value = payload.get(key) + if isinstance(value, Mapping): + sources.append(value) + for source in sources: + for key in keys: + if source.get(key) is not None: + return source[key] + return None + def verify_response_sign(self, payload: Mapping[str, Any], method: str) -> bool: """校验带 sign 的响应;供应商未返回 sign 时由调用方决定是否接受。""" received = str(payload.get("sign") or "").strip().lower() @@ -153,10 +175,12 @@ class FishFinRechargeClient: trace_event.update({ "url": url, "content_type": "application/json", - # 调试模式按用户要求保留完整协议内容,包含 sign 和 AppSecret。 - "json_body": request_params, + "json_body": self._debug_payload(request_params), "sign_params": sign_params, - "sign_source": f"{sign_query}{method.upper()}{self.config.app_secret}", + # 不把可重放签名原文或 AppSecret 写入日志,只记录待签名串摘要。 + "sign_source_digest": hashlib.sha256( + f"{sign_query}{method.upper()}".encode("utf-8") + ).hexdigest()[:12], }) self.trace(trace_event) try: @@ -179,9 +203,10 @@ class FishFinRechargeClient: "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 [], + "out_order_id": self._response_value(payload, "out_order_id", "outOrderId"), + "order_id": self._response_value(payload, "order_id", "orderId"), + "order_status": self._response_value(payload, "order_status", "orderStatus"), + "fail_reason": self._response_value(payload, "fail_reason", "failReason"), } if self.config.debug: trace_event.update({ @@ -190,8 +215,7 @@ class FishFinRechargeClient: 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, + "response_body": self._debug_payload(payload), }) self.trace(trace_event) return payload diff --git a/docker-compose.yml b/docker-compose.yml index 9d269f6..8a84135 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,8 @@ services: - ./logs:/app/logs environment: - TZ=Asia/Shanghai + # 应用日志等级由 .env 控制;生产环境默认只记录 INFO 及以上。 + - LOG_LEVEL=${LOG_LEVEL:-INFO} # MySQL 仅在 Compose 内网开放,不映射宿主机端口。 - DB_HOST=mysql - DB_PORT=3306 diff --git a/tests/test_douyu_gold_recharge_channel.py b/tests/test_douyu_gold_recharge_channel.py index 8aa05db..e64fd05 100644 --- a/tests/test_douyu_gold_recharge_channel.py +++ b/tests/test_douyu_gold_recharge_channel.py @@ -32,6 +32,7 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase): email="mail@example.com", email_password="mail-password", uid="10001", + nickname="罗炅729", ) self.session.add(self.account) self.session.commit() @@ -52,7 +53,7 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase): 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): + def test_supplier_channel_creates_order_using_nickname_and_finishes_on_success(self, client_class): supplier = Mock() supplier.create_order.return_value = { "code": 200, @@ -72,12 +73,13 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase): self.session, self.task, self.account, "acf_uid=10001", config, ) + expected_order_id = self.runner._supplier_out_order_id(self.task) supplier.create_order.assert_called_once_with( buy_num=10, pay_amount=unittest.mock.ANY, - out_order_id=f"DYGF{self.task.id}", + out_order_id=expected_order_id, product_id="gold-product", - recharge_arg=[{"templateName": "斗鱼账号", "templateVal": "10001"}], + recharge_arg=[{"templateName": "斗鱼账号", "templateVal": "罗炅729"}], order_type=0, notify_url=unittest.mock.ANY, ) @@ -87,12 +89,40 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase): 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["out_order_id"], f"DYGF{self.task.id}") + self.assertEqual(self.task.result["out_order_id"], expected_order_id) + self.assertEqual(self.task.result["recharge_account"], "罗炅729") + self.assertEqual(self.task.result["douyu_uid"], "10001") self.assertEqual(self.task.result["order_id"], "supplier-001") self.assertEqual(self.task.result["pay_amount"], "10") self.assertNotIn("pay_url", self.task.result) self.assertNotIn("sign", self.task.result["supplier_order"]) + @patch("web.backend.services.douyu_runner.FishFinRechargeClient") + def test_supplier_channel_rejects_account_without_nickname(self, client_class): + self.account.nickname = "" + self.session.commit() + config = { + "gold_recharge_channel": "supplier_api", + "gold_api_product_id": "gold-product", + "gold_api_account_template_name": "斗鱼昵称", + } + + self.runner._execute_create_gold_qr( + self.session, self.task, self.account, "acf_uid=10001", config, + ) + + client_class.assert_not_called() + self.session.refresh(self.task) + self.assertEqual(self.task.status, "failed") + self.assertIn("斗鱼昵称", self.task.message) + + def test_supplier_out_order_id_reuses_persisted_value(self): + self.task.supplier_out_order_id = "DYGFHISTORICAL-001" + self.assertEqual( + self.runner._supplier_out_order_id(self.task), + "DYGFHISTORICAL-001", + ) + @patch("web.backend.services.douyu_runner.FishFinRechargeClient") def test_supplier_channel_marks_non_200_create_response_as_failed(self, client_class): supplier = Mock() @@ -113,7 +143,7 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase): self.assertEqual(self.task.message, "商品已下架") def test_supplier_callback_verifies_signature_and_updates_terminal_task(self): - self.task.supplier_out_order_id = f"DYGF{self.task.id}" + self.task.supplier_out_order_id = f"DYGFBATCHT{self.task.id}" self.task.result = {"recharge_channel": "supplier_api", "out_order_id": self.task.supplier_out_order_id} self.session.commit() client = FishFinRechargeClient(FishFinRechargeConfig( diff --git a/tests/test_fish_fin_recharge_api.py b/tests/test_fish_fin_recharge_api.py index 238f930..68b82c2 100644 --- a/tests/test_fish_fin_recharge_api.py +++ b/tests/test_fish_fin_recharge_api.py @@ -137,12 +137,13 @@ class FishFinRechargeClientTests(unittest.TestCase): 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"]["sign"], "") self.assertEqual(events[0]["json_body"]["pay_amount"], 1) self.assertNotIn("sign", events[0]["sign_params"]) - self.assertIn(self.config.app_secret, events[0]["sign_source"]) - self.assertEqual(events[1]["response_body"]["sign"], "response-sign") - self.assertEqual(events[1]["response_text"], response.text) + self.assertNotIn(self.config.app_secret, str(events[0])) + self.assertIn("sign_source_digest", events[0]) + self.assertEqual(events[1]["response_body"]["sign"], "") + self.assertNotIn(self.config.app_secret, str(events[1])) if __name__ == "__main__": diff --git a/utils/logger.py b/utils/logger.py index 6eeb654..08ab500 100644 --- a/utils/logger.py +++ b/utils/logger.py @@ -9,6 +9,15 @@ from pathlib import Path from loguru import logger +_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} + + +def _normalize_log_level(level: str | None) -> str: + """规范化环境变量日志等级,非法值回退到 INFO。""" + normalized = str(level or "INFO").strip().upper() + return normalized if normalized in _LOG_LEVELS else "INFO" + + class _RelevantAccessFilter(logging.Filter): """仅保留异常 HTTP 请求,避免前端轮询淹没业务日志。""" @@ -67,6 +76,7 @@ def setup_logger(level: str = "INFO", log_dir: str = None, log_file: str = None) log_dir: 日志目录路径(写入 app.log,按日轮转) log_file: 日志文件路径(兼容旧接口,优先级低于 log_dir) """ + level = _normalize_log_level(level) # 移除默认 handler,由标准 logging 统一处理控制台和文件输出。 logger.remove() diff --git a/web/backend/main.py b/web/backend/main.py index 79de5a0..3704982 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -21,7 +21,7 @@ from utils import setup_logger @asynccontextmanager async def lifespan(app: FastAPI): # 初始化日志(控制台 + 按天命名文件) - _log_level = os.getenv("LOG_LEVEL", "DEBUG") + _log_level = os.getenv("LOG_LEVEL", "INFO") _log_dir = Path(__file__).resolve().parents[2] / "logs" setup_logger(level=_log_level, log_dir=str(_log_dir)) diff --git a/web/backend/migrations/versions/20260814_0028_douyu_nickname_recharge.py b/web/backend/migrations/versions/20260814_0028_douyu_nickname_recharge.py new file mode 100644 index 0000000..3ac303b --- /dev/null +++ b/web/backend/migrations/versions/20260814_0028_douyu_nickname_recharge.py @@ -0,0 +1,51 @@ +"""将斗鱼供应商直充默认账号模板切换为昵称。 + +Revision ID: 20260814_0028 +Revises: 20260813_0027 +Create Date: 2026-08-14 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260814_0028" +down_revision: Union[str, None] = "20260813_0027" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """只迁移历史默认值,保留运营人员明确配置的模板名。""" + bind = op.get_bind() + inspector = sa.inspect(bind) + if not inspector.has_table("douyu_config"): + return + columns = {column["name"] for column in inspector.get_columns("douyu_config")} + if "gold_api_account_template_name" in columns: + op.execute( + sa.text( + "UPDATE douyu_config SET gold_api_account_template_name = :new " + "WHERE gold_api_account_template_name IS NULL " + "OR TRIM(gold_api_account_template_name) = '' " + "OR gold_api_account_template_name = :old" + ).bindparams(new="斗鱼昵称", old="斗鱼UID") + ) + + +def downgrade() -> None: + """回滚时只回退本迁移设置的默认昵称。""" + bind = op.get_bind() + inspector = sa.inspect(bind) + if not inspector.has_table("douyu_config"): + return + columns = {column["name"] for column in inspector.get_columns("douyu_config")} + if "gold_api_account_template_name" in columns: + op.execute( + sa.text( + "UPDATE douyu_config SET gold_api_account_template_name = :old " + "WHERE gold_api_account_template_name = :new" + ).bindparams(new="斗鱼昵称", old="斗鱼UID") + ) diff --git a/web/backend/models.py b/web/backend/models.py index 1607965..4e40294 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -218,7 +218,7 @@ class DouyuConfig(Base): 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") + gold_api_account_template_name = Column(String(64), default="斗鱼昵称") 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/schemas.py b/web/backend/schemas.py index 615765e..7c18656 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -587,7 +587,7 @@ class DouyuConfigOut(BaseModel): gold_pay_type: int = 1 gold_recharge_channel: str = "wechat_qr" gold_api_product_id: str = "111570" - gold_api_account_template_name: str = "斗鱼UID" + gold_api_account_template_name: str = "斗鱼昵称" gift_id: str = "23643" skin_id: str = "2942" updated_at: Optional[datetime] = None diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index 4dabf8d..c34e360 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import re import threading import time from decimal import Decimal @@ -760,6 +761,15 @@ class DouyuBatchRunner: } return result + @staticmethod + def _supplier_out_order_id(task: DouyuTask) -> str: + """生成可追踪的供应商外部订单号;已有订单号必须在重试时复用。""" + existing = str(task.supplier_out_order_id or "").strip() + if existing: + return existing + batch_token = re.sub(r"[^A-Za-z0-9]", "", str(task.batch_id or "")).upper()[:16] or "LOCAL" + return f"DYGF{batch_token}T{task.id}" + def _wait_supplier_gold_order( self, db: Session, @@ -2456,7 +2466,7 @@ class DouyuBatchRunner: 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) + self._execute_create_gold_supplier_order(db, task, account, cookie, config, amount) except FishFinRechargeError as exc: self._mark_task(db, task, "failed", str(exc), {"recharge_channel": "supplier_api"}) return @@ -2500,24 +2510,28 @@ class DouyuBatchRunner: db: Session, task: DouyuTask, account: Account, + cookie: str, 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() + template_name = str(config.get("gold_api_account_template_name") or "斗鱼昵称").strip() if not product_id: raise FishFinRechargeError("请先在配置中填写供应商直充商品 ID") - charge_account = str(account.uid or "").strip() - if not charge_account: - raise FishFinRechargeError("账号缺少斗鱼 UID,无法发起供应商直充") + # 充值商品按斗鱼昵称识别账号,UID 只能作为审计信息,不能作为充值值。 + update_account_profile_from_cookie(account, cookie) + recharge_account = str(account.nickname or "").strip() + if not recharge_account: + raise FishFinRechargeError("账号缺少斗鱼昵称,无法发起供应商直充") - # task.id 是唯一且稳定的外部订单号来源,重试时不会创建不同的供应商订单。 - order_no = f"DYGF{task.id}" + # 首次生成后持久化,网络重试或进程重启都继续查询同一笔订单。 + order_no = self._supplier_out_order_id(task) task.supplier_out_order_id = order_no db.commit() # pay_amount 是用户选择的充值面值;goodsFaceValue=0.993 是供货成本,不能作为支付金额。 pay_amount = Decimal(amount) + def trace(event: dict) -> None: """将脱敏供应商协议信息输出到任务日志,便于线上联调。""" stage = event.get("stage") @@ -2525,36 +2539,41 @@ class DouyuBatchRunner: params = event.get("params") or {} self._push_log( "info", - "供应商直充请求 " - f"path={event.get('path')} out_order_id={params.get('out_order_id')} " - f"buy_num={params.get('buy_num')} pay_amount={params.get('pay_amount')} " - f"product_id={params.get('product_id')} types={event.get('parameter_types')} " - f"sign_digest={event.get('sign_digest')}", + "供应商直充 | 下单 " + f"| 外部单号={params.get('out_order_id') or '-'} " + f"| 数量={params.get('buy_num') or '-'} " + f"| 金额={params.get('pay_amount') or '-'} " + f"| 商品={params.get('product_id') or '-'}", ) 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')}", + "debug", + "供应商协议 | 请求 " + f"| {event.get('method')} {event.get('path')} " + f"| 签名摘要={event.get('sign_digest')} " + f"| 参数={FishFinRechargeClient._json_text(event['json_body'])}", ) elif stage == "response": + status = self._to_int(event.get("order_status")) + status_labels = {0: "待处理", 1: "处理中", 2: "成功", 3: "失败", 4: "异常"} + status_text = status_labels.get(status, "-") + reason = str(event.get("fail_reason") or event.get("message") or "-") 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')}", + "供应商直充 | 响应 " + f"| HTTP={event.get('http_status') or '-'} " + f"| 业务码={event.get('code') or '-'} " + f"| 外部单号={event.get('out_order_id') or '-'} " + f"| 供应商单号={event.get('order_id') or '-'} " + f"| 状态={status_text} " + f"| 提示={reason}", ) 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')}", + "debug", + "供应商协议 | 响应 " + f"| HTTP={event.get('http_status')} " + f"| 内容={FishFinRechargeClient._json_text(event['response_body'])}", ) client = FishFinRechargeClient(FishFinRechargeConfig.from_env(), trace=trace) @@ -2563,7 +2582,7 @@ class DouyuBatchRunner: pay_amount=pay_amount, out_order_id=order_no, product_id=product_id, - recharge_arg=[{"templateName": template_name, "templateVal": charge_account}], + recharge_arg=[{"templateName": template_name, "templateVal": recharge_account}], order_type=0, notify_url=client.config.notify_url, ) @@ -2573,7 +2592,8 @@ class DouyuBatchRunner: "recharge_channel": "supplier_api", "out_order_id": order_no, "order_id": self._supplier_value(order_payload, "order_id", "orderId"), - "charge_account": charge_account, + "recharge_account": recharge_account, + "douyu_uid": str(account.uid or "").strip(), "buy_num": amount, "product_id": product_id, "pay_amount": format(pay_amount.normalize(), "f"), diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index 345dd3a..b120de3 100644 --- a/web/backend/services/douyu_service.py +++ b/web/backend/services/douyu_service.py @@ -87,7 +87,7 @@ DOUYU_CONFIG_DEFAULTS = { "manual_id": "G4KA4Qnz4LDp7", "gold_pay_type": 1, "gold_recharge_channel": "wechat_qr", "gold_api_product_id": "111570", - "gold_api_account_template_name": "斗鱼UID", + "gold_api_account_template_name": "斗鱼昵称", "gift_id": "23643", "skin_id": "2942", "xpd_act_alias": "20260623KDQFH", diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index f88e70e..677d80a 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -2227,7 +2227,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) <> {[ { label: '供应商商品 ID', key: 'gold_api_product_id' }, - { label: '充值账号模板名', key: 'gold_api_account_template_name' }, + { label: '充值账号模板名(值为斗鱼昵称)', key: 'gold_api_account_template_name' }, ].map(({ label, key }) => ( {label}