完善虎牙外部单展示、状态恢复与礼包配置
This commit is contained in:
@@ -7,6 +7,9 @@ HUYA_DEFAULT_OUTER_ACT_ID = "17096"
|
||||
HUYA_DEFAULT_BIND_GID = "3203"
|
||||
HUYA_DEFAULT_BIND_ACT_ID = "9271"
|
||||
HUYA_DEFAULT_PAY_CHANNEL = "Zfb"
|
||||
HUYA_DEFAULT_POINTS_PAGE_CODE = "heping_baodian_credit_center_page"
|
||||
HUYA_DEFAULT_POINTS_SOURCE_ID = "yellowcarlist"
|
||||
HUYA_DEFAULT_POINTS_SCENE = "7"
|
||||
|
||||
HUYA_CONFIG_DEFAULTS = {
|
||||
"room_pid": HUYA_DEFAULT_ROOM_PID,
|
||||
@@ -15,6 +18,12 @@ HUYA_CONFIG_DEFAULTS = {
|
||||
"bind_gid": HUYA_DEFAULT_BIND_GID,
|
||||
"bind_act_id": HUYA_DEFAULT_BIND_ACT_ID,
|
||||
"pay_channel": HUYA_DEFAULT_PAY_CHANNEL,
|
||||
"points_page_code": HUYA_DEFAULT_POINTS_PAGE_CODE,
|
||||
"points_source_id": HUYA_DEFAULT_POINTS_SOURCE_ID,
|
||||
"points_scene": HUYA_DEFAULT_POINTS_SCENE,
|
||||
# 空 = 从 queryAllocation 响应的 tag0_2 自动发现。
|
||||
"points_page_id": "",
|
||||
"points_tab_ids": "",
|
||||
}
|
||||
|
||||
HUYA_CONFIG_FIELDS = tuple(HUYA_CONFIG_DEFAULTS)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""虎牙积分中心活动参数改为可配置。
|
||||
|
||||
礼包页签会随活动调整;pageCode/sourceId/scene 及可选 pageId/tabIds
|
||||
放进 ``huya_config``,避免每次换活动都改代码。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20261111_0076"
|
||||
down_revision: str | None = "20261109_0075"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
TABLE = "huya_config"
|
||||
COLUMNS: tuple[tuple[str, sa.String, str], ...] = (
|
||||
("points_page_code", sa.String(length=128), "heping_baodian_credit_center_page"),
|
||||
("points_source_id", sa.String(length=64), "yellowcarlist"),
|
||||
("points_scene", sa.String(length=16), "7"),
|
||||
("points_page_id", sa.String(length=32), ""),
|
||||
("points_tab_ids", sa.String(length=256), ""),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table(TABLE):
|
||||
return
|
||||
existing = {column["name"] for column in inspector.get_columns(TABLE)}
|
||||
for name, column_type, default in COLUMNS:
|
||||
if name in existing:
|
||||
continue
|
||||
op.add_column(
|
||||
TABLE,
|
||||
sa.Column(
|
||||
name,
|
||||
column_type,
|
||||
nullable=False,
|
||||
server_default=default,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table(TABLE):
|
||||
return
|
||||
existing = {column["name"] for column in inspector.get_columns(TABLE)}
|
||||
for name, _column_type, _default in reversed(COLUMNS):
|
||||
if name in existing:
|
||||
op.drop_column(TABLE, name)
|
||||
@@ -26,6 +26,9 @@ from .huya_defaults import (
|
||||
HUYA_DEFAULT_BIND_GID,
|
||||
HUYA_DEFAULT_OUTER_ACT_ID,
|
||||
HUYA_DEFAULT_PAY_CHANNEL,
|
||||
HUYA_DEFAULT_POINTS_PAGE_CODE,
|
||||
HUYA_DEFAULT_POINTS_SCENE,
|
||||
HUYA_DEFAULT_POINTS_SOURCE_ID,
|
||||
HUYA_DEFAULT_ROOM_PID,
|
||||
HUYA_DEFAULT_SID,
|
||||
)
|
||||
@@ -1377,6 +1380,29 @@ class HuyaConfig(Base):
|
||||
pay_channel: Mapped[str] = mapped_column(
|
||||
String(16), default=HUYA_DEFAULT_PAY_CHANNEL
|
||||
)
|
||||
# 积分中心 queryAllocation:活动换页时通常只需改这里,不用改代码。
|
||||
points_page_code: Mapped[str] = mapped_column(
|
||||
String(128),
|
||||
default=HUYA_DEFAULT_POINTS_PAGE_CODE,
|
||||
server_default=HUYA_DEFAULT_POINTS_PAGE_CODE,
|
||||
)
|
||||
points_source_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
default=HUYA_DEFAULT_POINTS_SOURCE_ID,
|
||||
server_default=HUYA_DEFAULT_POINTS_SOURCE_ID,
|
||||
)
|
||||
points_scene: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
default=HUYA_DEFAULT_POINTS_SCENE,
|
||||
server_default=HUYA_DEFAULT_POINTS_SCENE,
|
||||
)
|
||||
# 空 = 从响应自动发现;手动填写可作为接口改版时的兜底。
|
||||
points_page_id: Mapped[str] = mapped_column(
|
||||
String(32), default="", server_default=""
|
||||
)
|
||||
points_tab_ids: Mapped[str] = mapped_column(
|
||||
String(256), default="", server_default=""
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
UTCDateTime, default=_utcnow, onupdate=_utcnow
|
||||
)
|
||||
|
||||
@@ -456,6 +456,11 @@ def _config_out(config: HuyaConfig) -> HuyaConfigOut:
|
||||
bind_gid=huya_config_value("bind_gid", config.bind_gid),
|
||||
bind_act_id=huya_config_value("bind_act_id", config.bind_act_id),
|
||||
pay_channel=huya_config_value("pay_channel", config.pay_channel),
|
||||
points_page_code=huya_config_value("points_page_code", config.points_page_code),
|
||||
points_source_id=huya_config_value("points_source_id", config.points_source_id),
|
||||
points_scene=huya_config_value("points_scene", config.points_scene),
|
||||
points_page_id=huya_config_value("points_page_id", config.points_page_id),
|
||||
points_tab_ids=huya_config_value("points_tab_ids", config.points_tab_ids),
|
||||
updated_at=config.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -385,7 +385,7 @@ def create_external_order(
|
||||
"""新建一行(可同时贴一份外部 CK)。订单号唯一,重复会 409。
|
||||
|
||||
``draft=true``:先建单后补资料 —— 订单号 + 店铺即可,状态进「待补资料」,
|
||||
CK / 礼包名 / 主页截图稍后在详情里补(不跑探针)。
|
||||
CK / 礼包名 / 主页截图稍后在详情里补;带了 CK 仍会自动跑一次只读探针。
|
||||
|
||||
``require_verified``(默认 true):人工新建表单的严格模式 —— 订单编号 / 店铺 / CK /
|
||||
礼包名 / 主页截图必填,且 CK 必须探针通并读到绑定角色,否则 400
|
||||
@@ -405,6 +405,7 @@ def create_external_order(
|
||||
home_images=req.home_images,
|
||||
remark=req.remark,
|
||||
require_verified=req.require_verified,
|
||||
auto_verify_ck=True,
|
||||
draft=req.draft,
|
||||
)
|
||||
except collab.CollabError as exc:
|
||||
@@ -443,14 +444,15 @@ def preview_external_order_ck(
|
||||
新建弹窗据此决定能不能提交:``ok`` 且 ``has_role`` 才允许
|
||||
(服务端在创建时还会再校验一次,见 ``POST /external-orders``)。
|
||||
|
||||
礼包用**工作台配置里的活动 SID**(``huya_config.sid``)拉:SID 就是活动 ID,
|
||||
换活动就得换它,写死会出现「接口 200 但礼包列表是空的」(真机 2026-10-16)。
|
||||
礼包用积分中心 ``queryAllocation`` 拉取;pageCode/sourceId/scene 与可选页签覆盖
|
||||
来自虎牙配置,页签默认从响应自动发现并合并全部页签。
|
||||
"""
|
||||
try:
|
||||
preview = collab.preview_ck(
|
||||
req.ck_text,
|
||||
with_gifts=req.with_gifts,
|
||||
sid=collab.resolve_gifts_sid(db),
|
||||
points_config=collab.resolve_points_center_config(db),
|
||||
)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
@@ -502,6 +504,7 @@ def batch_update_external_order_status(
|
||||
req.status,
|
||||
reason=req.reason,
|
||||
rollback=req.rollback,
|
||||
force=req.force,
|
||||
)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
@@ -767,6 +770,7 @@ def update_external_order_status(
|
||||
req.status,
|
||||
reason=req.reason,
|
||||
rollback=req.rollback,
|
||||
force=req.force,
|
||||
)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
+25
-1
@@ -10,6 +10,9 @@ from .huya_defaults import (
|
||||
HUYA_DEFAULT_BIND_GID,
|
||||
HUYA_DEFAULT_OUTER_ACT_ID,
|
||||
HUYA_DEFAULT_PAY_CHANNEL,
|
||||
HUYA_DEFAULT_POINTS_PAGE_CODE,
|
||||
HUYA_DEFAULT_POINTS_SCENE,
|
||||
HUYA_DEFAULT_POINTS_SOURCE_ID,
|
||||
HUYA_DEFAULT_ROOM_PID,
|
||||
HUYA_DEFAULT_SID,
|
||||
)
|
||||
@@ -1158,6 +1161,11 @@ class HuyaConfigOut(BaseModel):
|
||||
bind_gid: str = HUYA_DEFAULT_BIND_GID
|
||||
bind_act_id: str = HUYA_DEFAULT_BIND_ACT_ID
|
||||
pay_channel: str = HUYA_DEFAULT_PAY_CHANNEL
|
||||
points_page_code: str = HUYA_DEFAULT_POINTS_PAGE_CODE
|
||||
points_source_id: str = HUYA_DEFAULT_POINTS_SOURCE_ID
|
||||
points_scene: str = HUYA_DEFAULT_POINTS_SCENE
|
||||
points_page_id: str = ""
|
||||
points_tab_ids: str = ""
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@model_serializer
|
||||
@@ -1169,6 +1177,11 @@ class HuyaConfigOut(BaseModel):
|
||||
"bind_gid": self.bind_gid,
|
||||
"bind_act_id": self.bind_act_id,
|
||||
"pay_channel": self.pay_channel,
|
||||
"points_page_code": self.points_page_code,
|
||||
"points_source_id": self.points_source_id,
|
||||
"points_scene": self.points_scene,
|
||||
"points_page_id": self.points_page_id,
|
||||
"points_tab_ids": self.points_tab_ids,
|
||||
"updated_at": _isoformat(self.updated_at),
|
||||
}
|
||||
|
||||
@@ -1180,6 +1193,11 @@ class HuyaConfigUpdate(BaseModel):
|
||||
bind_gid: str | None = None
|
||||
bind_act_id: str | None = None
|
||||
pay_channel: str | None = None
|
||||
points_page_code: str | None = None
|
||||
points_source_id: str | None = None
|
||||
points_scene: str | None = None
|
||||
points_page_id: str | None = None
|
||||
points_tab_ids: str | None = None
|
||||
|
||||
|
||||
class HuyaTaskBatchRequest(BaseModel):
|
||||
@@ -1727,6 +1745,7 @@ class HuyaCollabCkPreviewOut(BaseModel):
|
||||
uid: str = ""
|
||||
yyuid: str = ""
|
||||
hy_username: str = ""
|
||||
nickname: str = ""
|
||||
ck_status: str = "unverified"
|
||||
bind_game_account: str = ""
|
||||
bind_game_role: str = ""
|
||||
@@ -1745,7 +1764,7 @@ class HuyaExternalOrderCreate(BaseModel):
|
||||
礼包名 / **主页截图**必填,且 CK 必须探针通并读到绑定角色;粘贴导入不走这条路。
|
||||
|
||||
``draft``(默认关):先建单后补资料 —— 只登记订单号 + 店铺,状态进「待补资料」,
|
||||
CK / 礼包名 / 主页截图都可以稍后在详情里补(2026-10-17 用户需求)。
|
||||
CK / 礼包名 / 主页截图都可以稍后在详情里补;带了 CK 仍会自动跑一次只读探针。
|
||||
"""
|
||||
|
||||
order_no: str = Field(..., min_length=1, max_length=64)
|
||||
@@ -1785,6 +1804,8 @@ class HuyaExternalOrderStatusRequest(BaseModel):
|
||||
reason: str = Field("", max_length=256)
|
||||
# 目标不是「向前一步」时必须显式置 True,避免误点把状态改回去。
|
||||
rollback: bool = False
|
||||
# 仅超级管理员可用:从「已作废」强制恢复,必须同时填写 reason。
|
||||
force: bool = False
|
||||
expected_version: int | None = None
|
||||
|
||||
|
||||
@@ -1858,6 +1879,7 @@ class HuyaExternalOrderVerifyItemOut(BaseModel):
|
||||
ok: bool = False
|
||||
ck_status: str = ""
|
||||
message: str = ""
|
||||
nickname: str = ""
|
||||
binding: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -1878,6 +1900,8 @@ class HuyaExternalOrderStatusBatchRequest(BaseModel):
|
||||
reason: str = Field("", max_length=256)
|
||||
# 目标不是「向前」时必须显式置 True(和单行接口一致,避免误点)
|
||||
rollback: bool = False
|
||||
# 仅超级管理员可用;混合批次里只对 void 行生效,必须同时填写 reason。
|
||||
force: bool = False
|
||||
|
||||
|
||||
class HuyaExternalOrderStatusBatchItemOut(BaseModel):
|
||||
|
||||
@@ -35,6 +35,9 @@ from core.huya.http_client import query_bind_game_account
|
||||
from ..huya_defaults import (
|
||||
HUYA_DEFAULT_BIND_GID,
|
||||
HUYA_DEFAULT_OUTER_ACT_ID,
|
||||
HUYA_DEFAULT_POINTS_PAGE_CODE,
|
||||
HUYA_DEFAULT_POINTS_SCENE,
|
||||
HUYA_DEFAULT_POINTS_SOURCE_ID,
|
||||
HUYA_DEFAULT_SID,
|
||||
)
|
||||
from ..models import (
|
||||
@@ -48,7 +51,11 @@ from ..models import (
|
||||
from .audit_service import record_audit
|
||||
from .file_service import build_file_url
|
||||
from .file_storage import normalize_object_key
|
||||
from .huya_service import build_web_cookie_from_raw, parse_huya_cookie_line
|
||||
from .huya_service import (
|
||||
build_web_cookie_from_raw,
|
||||
huya_config_value,
|
||||
parse_huya_cookie_line,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 业务状态机(一单卡在哪一步)
|
||||
@@ -217,6 +224,7 @@ FIELD_LIMITS = {
|
||||
"remark": 512,
|
||||
"void_reason": 256,
|
||||
"archive_reason": 256,
|
||||
"force_reason": 256,
|
||||
}
|
||||
|
||||
FIELD_LABELS = {
|
||||
@@ -230,6 +238,7 @@ FIELD_LABELS = {
|
||||
"required_points": "礼包所需积分",
|
||||
"remark": "备注",
|
||||
"void_reason": "作废原因",
|
||||
"force_reason": "强制恢复原因",
|
||||
"archive_reason": "归档备注",
|
||||
}
|
||||
|
||||
@@ -457,6 +466,8 @@ def _apply_ck(order: HuyaExternalOrder, cookie_text: str) -> dict:
|
||||
order.uid = str(parsed.get("uid") or "")
|
||||
order.yyuid = str(parsed.get("yyuid") or "")
|
||||
order.hy_username = str(parsed.get("username") or "")[:128]
|
||||
# 换 CK 后旧昵称不再可信;等下一次验证时由虎牙用户资料接口重新补全。
|
||||
order.nickname = ""
|
||||
order.cookie = cookie
|
||||
# 粘贴的若是 App 原始凭据(没有网页设备字段),顺手留一份 raw_cookie,
|
||||
# 以后被风控标记时能直接「重建」而不必重新要 CK。
|
||||
@@ -618,6 +629,7 @@ def set_status(
|
||||
*,
|
||||
reason: str = "",
|
||||
rollback: bool = False,
|
||||
force: bool = False,
|
||||
allow_recharge: bool = False,
|
||||
commit: bool = True,
|
||||
) -> dict:
|
||||
@@ -625,6 +637,9 @@ def set_status(
|
||||
|
||||
``recharged`` / ``points_error`` 只能由充值核账流程写入(``allow_recharge=True``);
|
||||
普通人工状态接口不允许绕过核账。
|
||||
|
||||
``force=True`` 仅用于超级管理员把 ``void`` 恢复到流程内:必须填写原因,
|
||||
只跳过“作废不可逆”限制,不绕过资料完整性与核账校验。
|
||||
"""
|
||||
if target not in ORDER_STATUSES:
|
||||
raise CollabError(f"未知状态:{target}", code="status_unknown")
|
||||
@@ -638,9 +653,24 @@ def set_status(
|
||||
return {}
|
||||
|
||||
from_status = order.status
|
||||
force_applied = False
|
||||
force_reason = ""
|
||||
if force:
|
||||
if current is None or current.role != "super_admin":
|
||||
raise CollabError(
|
||||
"只有超级管理员可以强制恢复已作废的订单",
|
||||
status_code=403,
|
||||
code="force_admin_required",
|
||||
)
|
||||
if from_status == STATUS_VOID:
|
||||
force_reason = clean_text(reason, field="force_reason", required=True)
|
||||
force_applied = True
|
||||
|
||||
forward = is_forward_status(from_status, target)
|
||||
if not forward:
|
||||
if not rollback:
|
||||
if force_applied:
|
||||
pass
|
||||
elif not rollback:
|
||||
next_status = NEXT_STATUS.get(from_status)
|
||||
hint = (
|
||||
f"要往前的话下一步是「{ORDER_STATUS_LABELS[next_status]}」(也可以直接跳到更后面的状态)"
|
||||
@@ -652,7 +682,7 @@ def set_status(
|
||||
f"「{ORDER_STATUS_LABELS[target]}」({hint});确实要回退请显式勾选「回退」",
|
||||
code="status_not_forward",
|
||||
)
|
||||
if from_status == STATUS_VOID:
|
||||
elif from_status == STATUS_VOID:
|
||||
raise CollabError(
|
||||
"已作废的单子不能改回流程内状态,请编辑或新建",
|
||||
code="status_from_void",
|
||||
@@ -668,6 +698,7 @@ def set_status(
|
||||
_assert_can_enter_to_recharge(order)
|
||||
|
||||
now = _now()
|
||||
previous_void_reason = order.void_reason or ""
|
||||
if target == STATUS_VOID:
|
||||
order.void_reason = clean_text(reason, field="void_reason", required=True)
|
||||
else:
|
||||
@@ -689,6 +720,9 @@ def set_status(
|
||||
"to": target,
|
||||
"rollback": bool(not forward),
|
||||
"reason": order.void_reason,
|
||||
"force": force_applied,
|
||||
"force_reason": force_reason,
|
||||
"previous_void_reason": previous_void_reason if force_applied else "",
|
||||
"order_no": order.order_no,
|
||||
"allow_recharge": bool(allow_recharge),
|
||||
},
|
||||
@@ -707,6 +741,7 @@ def set_status_batch(
|
||||
*,
|
||||
reason: str = "",
|
||||
rollback: bool = False,
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
"""批量改状态:勾选多行一次切到同一个状态(部分失败不影响其它行)。
|
||||
|
||||
@@ -740,6 +775,7 @@ def set_status_batch(
|
||||
target,
|
||||
reason=reason,
|
||||
rollback=rollback,
|
||||
force=force,
|
||||
commit=False,
|
||||
)
|
||||
except CollabError as exc:
|
||||
@@ -850,6 +886,7 @@ def _resolve_group_required_points(
|
||||
int(source.uid),
|
||||
str(source.cookie),
|
||||
client=client,
|
||||
**resolve_points_center_config(db),
|
||||
)
|
||||
if not catalog:
|
||||
return [order.order_no for order in missing], message
|
||||
@@ -1469,6 +1506,8 @@ class CkProbeResult:
|
||||
ok: bool
|
||||
message: str
|
||||
snapshot: dict[str, str] = dataclasses.field(default_factory=dict)
|
||||
nickname: str = ""
|
||||
nickname_error: str = ""
|
||||
|
||||
|
||||
def _build_probe_client() -> Any:
|
||||
@@ -1488,9 +1527,9 @@ def _probe_snapshot(resp: Any) -> dict[str, str]:
|
||||
"""探针读到的**绑定对象**:绑定在该虎牙号上的游戏账号 + 角色。
|
||||
|
||||
2026-10-16 真机确认:``check_user_bind_game_account`` 返回的是**游戏账号**
|
||||
(账号昵称 / 角色名 / 平台·区服),**不是虎牙号自己的昵称** —— 虎牙昵称要
|
||||
`get_grow_level_user_info`,可那个接口不认凭据、当不了探针,所以 ``nickname``
|
||||
一个字段都不碰(那是「虎牙号昵称」,硬写就是假数据)。
|
||||
(账号昵称 / 角色名 / 平台·区服),**不是虎牙号自己的昵称**。本函数只组装游戏
|
||||
绑定快照;虎牙平台昵称由 :func:`_probe_profile_nickname` 在绑定探针通过后补读,
|
||||
绝不能拿 ``gameAccount.nick`` 冒充。
|
||||
|
||||
这几个列由探针**独占并覆盖写入**(``bind_snapshot_at`` 记读取时间):
|
||||
"这单现在到底绑了哪个号、哪个角色"只有这里可信(人工填的是自由文本)。
|
||||
@@ -1548,6 +1587,44 @@ def _probe_success_message(resp: Any) -> str:
|
||||
return (f"有效:{detail}" if detail else "有效")[:_CK_MESSAGE_LIMIT]
|
||||
|
||||
|
||||
def _probe_profile_nickname(client: Any, *, uid: int, cookie: str) -> tuple[str, str]:
|
||||
"""在绑定探针成功后补读虎牙平台昵称。
|
||||
|
||||
这是**展示辅助**,不参与 CK 有效性判断:``check_user_bind_game_account``
|
||||
已经确认凭据有效;用户资料接口失败时保留“CK 有效”,只把失败原因带回 UI。
|
||||
不用 ``gameAccount.nick`` 顶替,两者语义不同(游戏账号 vs 虎牙平台账号)。
|
||||
"""
|
||||
method = getattr(client, "get_user_profile_batch", None)
|
||||
if method is None:
|
||||
return "", ""
|
||||
try:
|
||||
resp = method(
|
||||
uid=uid,
|
||||
cookie=cookie,
|
||||
target_uids=[uid],
|
||||
timeout=PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 昵称失败不能影响 CK 主结论
|
||||
logger.debug(f"[huya-collab] 虎牙昵称查询异常:{type(exc).__name__}: {exc}")
|
||||
return "", f"{type(exc).__name__}: {_clip(exc)}"
|
||||
|
||||
if resp is None:
|
||||
return "", "虎牙昵称接口没有返回数据"
|
||||
profiles = list(getattr(resp, "profiles", None) or [])
|
||||
if not profiles:
|
||||
return "", "虎牙昵称接口没有返回用户资料"
|
||||
profile = next(
|
||||
(item for item in profiles if int(getattr(item, "uid", 0) or 0) == uid),
|
||||
None,
|
||||
)
|
||||
if profile is None:
|
||||
return "", "虎牙昵称接口返回的 uid 不匹配"
|
||||
nickname = str(getattr(profile, "nick", "") or "").strip()
|
||||
if not nickname:
|
||||
return "", "虎牙昵称接口没有返回昵称"
|
||||
return _clip(nickname, 128), ""
|
||||
|
||||
|
||||
def probe_ck(order: HuyaExternalOrder, *, client: Any | None = None) -> CkProbeResult:
|
||||
"""只读探针:这份 CK 还能不能过(不写库、不发敏感动作)。
|
||||
|
||||
@@ -1599,7 +1676,21 @@ def probe_ck(order: HuyaExternalOrder, *, client: Any | None = None) -> CkProbeR
|
||||
msg = _clip(getattr(resp, "msg", ""))
|
||||
suffix = f":{msg}" if msg else ""
|
||||
return CkProbeResult(False, f"探针返回 status={status}{suffix}")
|
||||
result = CkProbeResult(True, _probe_success_message(resp), _probe_snapshot(resp))
|
||||
nickname, nickname_error = _probe_profile_nickname(
|
||||
probe_client, uid=uid, cookie=cookie
|
||||
)
|
||||
message = _probe_success_message(resp)
|
||||
if nickname:
|
||||
message = f"{message};虎牙昵称 {nickname}"[:_CK_MESSAGE_LIMIT]
|
||||
elif nickname_error:
|
||||
message = f"{message};虎牙昵称未读到:{nickname_error}"[:_CK_MESSAGE_LIMIT]
|
||||
result = CkProbeResult(
|
||||
True,
|
||||
message,
|
||||
_probe_snapshot(resp),
|
||||
nickname=nickname,
|
||||
nickname_error=nickname_error,
|
||||
)
|
||||
if cache_key:
|
||||
_probe_cache[cache_key] = (time.time(), result)
|
||||
return result
|
||||
@@ -1628,6 +1719,49 @@ def resolve_gifts_sid(db: Session) -> int:
|
||||
return value or GIFTS_SID
|
||||
|
||||
|
||||
def resolve_points_center_config(db: Session) -> dict[str, Any]:
|
||||
"""积分中心活动参数:优先读虎牙配置,空值/脏值回落到默认。
|
||||
|
||||
页签 ID 默认留空,由 ``queryAllocation`` 响应自动发现;``points_page_id``
|
||||
与 ``points_tab_ids`` 只作为接口改版、自动发现失败时的人工覆盖。
|
||||
"""
|
||||
row = db.query(HuyaConfig).first()
|
||||
|
||||
def value(field: str, default: str) -> str:
|
||||
return (
|
||||
huya_config_value(
|
||||
field, getattr(row, field, None) if row is not None else None
|
||||
)
|
||||
or default
|
||||
)
|
||||
|
||||
try:
|
||||
scene = int(value("points_scene", HUYA_DEFAULT_POINTS_SCENE))
|
||||
except (TypeError, ValueError):
|
||||
scene = int(HUYA_DEFAULT_POINTS_SCENE)
|
||||
|
||||
page_id_text = value("points_page_id", "")
|
||||
try:
|
||||
page_id = int(page_id_text) if page_id_text else None
|
||||
except ValueError:
|
||||
page_id = None
|
||||
|
||||
tab_ids: list[int] = []
|
||||
for part in re.split(r"[,,\s]+", value("points_tab_ids", "")):
|
||||
if part.isdigit():
|
||||
tab_id = int(part)
|
||||
if tab_id not in tab_ids:
|
||||
tab_ids.append(tab_id)
|
||||
|
||||
return {
|
||||
"page_code": value("points_page_code", HUYA_DEFAULT_POINTS_PAGE_CODE),
|
||||
"source_id": value("points_source_id", HUYA_DEFAULT_POINTS_SOURCE_ID),
|
||||
"scene": scene,
|
||||
"page_id": page_id,
|
||||
"tab_ids": tab_ids or None,
|
||||
}
|
||||
|
||||
|
||||
def fetch_activity_gifts(
|
||||
uid: int,
|
||||
cookie: str,
|
||||
@@ -1692,52 +1826,149 @@ def fetch_points_center_gifts(
|
||||
cookie: str,
|
||||
*,
|
||||
client: Any | None = None,
|
||||
page_code: str = HUYA_DEFAULT_POINTS_PAGE_CODE,
|
||||
source_id: str = HUYA_DEFAULT_POINTS_SOURCE_ID,
|
||||
scene: int = int(HUYA_DEFAULT_POINTS_SCENE),
|
||||
page_id: int | None = None,
|
||||
tab_ids: Sequence[int] | None = None,
|
||||
) -> tuple[list[dict], str]:
|
||||
"""用这份 CK 拉**积分中心商品**(``PlayMallUI.queryAllocation``,只读)。
|
||||
|
||||
2026-10-19 用户拍板:外部单协作台账改用这个接口,不再调用
|
||||
``webActUI.getActPrizeList``。商品积分取 ``tag75.tag0.tag2``,
|
||||
与积分中心页面显示一致;旧接口保留给精英宝典工作台的兑换链路。
|
||||
|
||||
2026-11-11 HAR 确认:基础请求只返回第一个页签的商品,其余页签定义在
|
||||
``tag2[].tag0_2``,需要带 ``tag9=pageId`` 与
|
||||
``tag7={"waterfallTabId":"tabId"}`` 再查。这里默认自动发现并拉全,
|
||||
``page_id`` / ``tab_ids`` 仅作为配置覆盖。
|
||||
"""
|
||||
try:
|
||||
gift_client = client or _build_probe_client()
|
||||
resp = gift_client.play_mall_query_allocation(
|
||||
uid=int(uid),
|
||||
guid="",
|
||||
cookie=cookie,
|
||||
deep=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 拉礼包失败不该让整个预览 500
|
||||
logger.debug(f"[huya-collab] 拉取积分中心商品异常:{type(exc).__name__}: {exc}")
|
||||
return [], f"拉取礼包失败:{type(exc).__name__}: {_clip(exc)}"
|
||||
from core.huya.shop_structs import (
|
||||
extract_points_center_tabs,
|
||||
parse_points_center_gifts,
|
||||
)
|
||||
|
||||
if resp is None:
|
||||
return [], "拉取礼包失败:积分中心接口没有返回数据"
|
||||
gift_client = client or _build_probe_client()
|
||||
|
||||
code = getattr(resp, "code", None)
|
||||
if code is None:
|
||||
code = getattr(resp, "status", 0)
|
||||
code = int(code or 0)
|
||||
if code != 200:
|
||||
msg = _clip(getattr(resp, "message", "") or getattr(resp, "msg", ""))
|
||||
return [], f"拉取礼包失败:code={code}{(':' + msg) if msg else ''}"
|
||||
|
||||
payload = getattr(resp, "payload", None)
|
||||
if payload is None and hasattr(resp, "to_dict"):
|
||||
def call(
|
||||
tab_id: int | None = None, tab_page_id: int | None = None
|
||||
) -> tuple[dict | None, str]:
|
||||
kwargs: dict[str, Any] = {
|
||||
"uid": int(uid),
|
||||
"guid": "",
|
||||
"cookie": cookie,
|
||||
"page_code": page_code,
|
||||
"source_id": source_id,
|
||||
"scene": scene,
|
||||
"deep": True,
|
||||
}
|
||||
if tab_id is not None:
|
||||
kwargs["page_id"] = int(page_id or tab_page_id or 0)
|
||||
kwargs["waterfall_tab_id"] = int(tab_id)
|
||||
try:
|
||||
payload = (resp.to_dict() or {}).get("payload")
|
||||
except Exception: # noqa: BLE001 第三方响应结构异常时按空处理
|
||||
payload = None
|
||||
resp = gift_client.play_mall_query_allocation(**kwargs)
|
||||
except Exception as exc: # noqa: BLE001 单个页签失败不该带崩整批
|
||||
logger.debug(
|
||||
f"[huya-collab] 拉取积分中心商品异常:{type(exc).__name__}: {exc}"
|
||||
)
|
||||
return None, f"{type(exc).__name__}: {_clip(exc)}"
|
||||
|
||||
from core.huya.shop_structs import parse_points_center_gifts
|
||||
if resp is None:
|
||||
return None, "积分中心接口没有返回数据"
|
||||
|
||||
gifts = parse_points_center_gifts(payload)[:GIFTS_LIMIT]
|
||||
code = getattr(resp, "code", None)
|
||||
if code is None:
|
||||
code = getattr(resp, "status", 0)
|
||||
code = int(code or 0)
|
||||
if code != 200:
|
||||
msg = _clip(getattr(resp, "message", "") or getattr(resp, "msg", ""))
|
||||
return None, f"code={code}{(':' + msg) if msg else ''}"
|
||||
|
||||
payload = getattr(resp, "payload", None)
|
||||
if payload is None and hasattr(resp, "to_dict"):
|
||||
try:
|
||||
payload = (resp.to_dict() or {}).get("payload")
|
||||
except Exception: # noqa: BLE001 第三方响应结构异常时按空处理
|
||||
payload = None
|
||||
return payload if isinstance(payload, dict) else None, ""
|
||||
|
||||
payload, error = call()
|
||||
if error:
|
||||
return [], f"拉取礼包失败:{error}"
|
||||
|
||||
gifts = parse_points_center_gifts(payload)
|
||||
tabs = extract_points_center_tabs(payload)
|
||||
if not gifts and not tabs:
|
||||
return [], (
|
||||
"积分中心没返回商品:接口通了但列表是空的,"
|
||||
"检查账号是否仍有积分中心访问权限,或稍后重试"
|
||||
)
|
||||
|
||||
discovered = {(int(tab["page_id"]), int(tab["tab_id"])): tab for tab in tabs}
|
||||
loaded_categories = {str(gift.get("category_name") or "").strip() for gift in gifts}
|
||||
tab_errors: list[str] = []
|
||||
if tab_ids:
|
||||
targets: list[dict] = []
|
||||
for tab_id in tab_ids:
|
||||
matched = next(
|
||||
(
|
||||
tab
|
||||
for (tab_page_id, tab_id_value), tab in discovered.items()
|
||||
if tab_id_value == int(tab_id)
|
||||
and (page_id is None or tab_page_id == int(page_id))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matched is not None:
|
||||
targets.append(matched)
|
||||
elif page_id is not None:
|
||||
targets.append(
|
||||
{
|
||||
"page_id": int(page_id),
|
||||
"tab_id": int(tab_id),
|
||||
"name": f"页签 {tab_id}",
|
||||
}
|
||||
)
|
||||
else:
|
||||
tab_errors.append(f"页签 {tab_id}:响应里没发现,且未配置 pageId")
|
||||
else:
|
||||
targets = [
|
||||
tab
|
||||
for tab in tabs
|
||||
if str(tab.get("name") or "").strip() not in loaded_categories
|
||||
]
|
||||
|
||||
seen_products = {
|
||||
str(gift.get("product_id") or "") for gift in gifts if gift.get("product_id")
|
||||
}
|
||||
loaded_tabs = len(loaded_categories) if gifts else 0
|
||||
for tab in targets:
|
||||
target_page_id = int(page_id or tab["page_id"])
|
||||
tab_payload, tab_error = call(int(tab["tab_id"]), target_page_id)
|
||||
if tab_error:
|
||||
tab_errors.append(f"{tab.get('name') or tab['tab_id']}:{tab_error}")
|
||||
continue
|
||||
tab_gifts = parse_points_center_gifts(tab_payload)
|
||||
added = 0
|
||||
for gift in tab_gifts:
|
||||
product_id = str(gift.get("product_id") or "")
|
||||
if product_id and product_id not in seen_products:
|
||||
seen_products.add(product_id)
|
||||
gifts.append(gift)
|
||||
added += 1
|
||||
if added or tab_gifts:
|
||||
loaded_tabs += 1
|
||||
|
||||
gifts = gifts[:GIFTS_LIMIT]
|
||||
if not gifts:
|
||||
return [], (
|
||||
"积分中心没返回商品:接口通了但列表是空的,"
|
||||
"检查账号是否有积分中心访问权限,或稍后重试"
|
||||
"检查账号是否仍有积分中心访问权限,或稍后重试"
|
||||
)
|
||||
return gifts, f"已拉取 {len(gifts)} 个礼包(积分中心)"
|
||||
suffix = f",{loaded_tabs} 个页签" if loaded_tabs > 1 else ""
|
||||
if tab_errors:
|
||||
suffix += f";部分页签未加载:{';'.join(tab_errors[:3])}"
|
||||
return gifts, f"已拉取 {len(gifts)} 个礼包(积分中心{suffix})"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -2290,6 +2521,7 @@ def preview_ck(
|
||||
*,
|
||||
with_gifts: bool = True,
|
||||
sid: int | None = None,
|
||||
points_config: Mapping[str, Any] | None = None,
|
||||
client: Any | None = None,
|
||||
) -> dict:
|
||||
"""**只读预览**一份待提交的 CK:解析 + 探针(+ 顺带拉积分中心礼包),一行都不写库。
|
||||
@@ -2313,7 +2545,10 @@ def preview_ck(
|
||||
gifts_message = ""
|
||||
if with_gifts and result.ok and uid_text:
|
||||
gifts, gifts_message = fetch_points_center_gifts(
|
||||
int(uid_text), cookie, client=client
|
||||
int(uid_text),
|
||||
cookie,
|
||||
client=client,
|
||||
**(dict(points_config) if points_config else {}),
|
||||
)
|
||||
return {
|
||||
"ok": result.ok,
|
||||
@@ -2321,6 +2556,7 @@ def preview_ck(
|
||||
"uid": uid_text,
|
||||
"yyuid": str(parsed.get("yyuid") or ""),
|
||||
"hy_username": str(parsed.get("username") or ""),
|
||||
"nickname": result.nickname,
|
||||
"ck_status": CK_VALID if result.ok else CK_INVALID,
|
||||
"bind_game_account": result.snapshot.get("bind_game_account", ""),
|
||||
"bind_game_role": result.snapshot.get("bind_game_role", ""),
|
||||
@@ -2361,16 +2597,20 @@ def verify_ck(
|
||||
field_name: str(getattr(order, field_name) or "")
|
||||
for field_name in PROBE_SNAPSHOT_FIELDS
|
||||
}
|
||||
nickname_before = str(order.nickname or "")
|
||||
if result.ok:
|
||||
before = dict(binding)
|
||||
for field_name in PROBE_SNAPSHOT_FIELDS:
|
||||
setattr(order, field_name, result.snapshot.get(field_name, ""))
|
||||
if result.nickname:
|
||||
order.nickname = result.nickname
|
||||
order.bind_snapshot_at = _now()
|
||||
binding = {
|
||||
field_name: str(getattr(order, field_name) or "")
|
||||
for field_name in PROBE_SNAPSHOT_FIELDS
|
||||
}
|
||||
if binding != before:
|
||||
nickname_after = str(order.nickname or "")
|
||||
if binding != before or nickname_after != nickname_before:
|
||||
# 快照变了(或第一次读到)才记一条,别每次验证都刷两行审计。
|
||||
record_audit(
|
||||
db,
|
||||
@@ -2381,6 +2621,8 @@ def verify_ck(
|
||||
"order_no": order.order_no,
|
||||
"before": before,
|
||||
"after": binding,
|
||||
"nickname_before": nickname_before,
|
||||
"nickname_after": nickname_after,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2397,6 +2639,7 @@ def verify_ck(
|
||||
"ck_status": target,
|
||||
"message": result.message,
|
||||
"binding": binding,
|
||||
"nickname": str(order.nickname or ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -2408,6 +2651,7 @@ def _probe_row_error(order: HuyaExternalOrder, message: str) -> dict:
|
||||
"ok": False,
|
||||
"ck_status": order.ck_status,
|
||||
"message": _clip(message, _CK_MESSAGE_LIMIT),
|
||||
"nickname": order.nickname or "",
|
||||
"binding": {
|
||||
field_name: str(getattr(order, field_name) or "")
|
||||
for field_name in PROBE_SNAPSHOT_FIELDS
|
||||
@@ -3167,6 +3411,7 @@ def create_order(
|
||||
home_images: Any = None,
|
||||
remark: str = "",
|
||||
require_verified: bool = False,
|
||||
auto_verify_ck: bool = False,
|
||||
draft: bool = False,
|
||||
client: Any | None = None,
|
||||
commit: bool = True,
|
||||
@@ -3178,7 +3423,8 @@ def create_order(
|
||||
|
||||
``draft=True``(**先建单后补资料**):只要求订单号,店铺也允许先空着;
|
||||
状态直接进「待补资料」,CK / 礼包名 / 主页截图都可以等客户资料到了再补。
|
||||
这条路径不跑探针、不做严格校验(2026-10-17 用户需求)。
|
||||
这条路径不做严格校验;但只要带了 CK,就会顺手跑一次只读探针并写入绑定快照,
|
||||
探针失败也不阻止建单。
|
||||
|
||||
``require_verified=True``(**人工新建表单用**,粘贴导入不传)时按"人工必填项"严格校验:
|
||||
**订单编号 / 店铺 / CK / 礼包名 / 主页截图**都不许空,且 CK 必须探针通、
|
||||
@@ -3227,7 +3473,6 @@ def create_order(
|
||||
# 同一个虎牙号允许挂多单(2026-10-16 放开 uid 唯一性):只记复用痕迹,不拦
|
||||
uid_reused = [item.order_no for item in uid_used_orders(db, order.uid)]
|
||||
|
||||
probe_result: CkProbeResult | None = None
|
||||
if require_verified:
|
||||
missing: list[str] = []
|
||||
if not order.shop:
|
||||
@@ -3242,8 +3487,14 @@ def create_order(
|
||||
raise CollabError(
|
||||
f"下面这几项必须填:{'、'.join(missing)}", code="create_field_required"
|
||||
)
|
||||
# 先探针再落库:读不到绑定角色就不许建(临时对象,不会留下半行数据)。
|
||||
|
||||
# 只要带了 CK 就先探一次:严格建单失败会阻断;草稿/导入等宽松路径则保留探针
|
||||
# 结果,但绝不因为外部接口失败而阻止建单。这样新行不需要再手动点一次「验证 CK」。
|
||||
probe_result: CkProbeResult | None = None
|
||||
if ck_provided and (require_verified or auto_verify_ck):
|
||||
probe_result = probe_ck(order, client=client)
|
||||
|
||||
if require_verified and probe_result is not None:
|
||||
role = probe_result.snapshot.get("bind_game_role", "")
|
||||
if not probe_result.ok or not role:
|
||||
raise CollabError(
|
||||
@@ -3251,8 +3502,12 @@ def create_order(
|
||||
+ ("(探针通了但读不到绑定角色)" if probe_result.ok else ""),
|
||||
code="ck_not_verified",
|
||||
)
|
||||
|
||||
if probe_result is not None and probe_result.ok:
|
||||
for field_name in PROBE_SNAPSHOT_FIELDS:
|
||||
setattr(order, field_name, probe_result.snapshot.get(field_name, ""))
|
||||
if probe_result.nickname:
|
||||
order.nickname = probe_result.nickname
|
||||
order.bind_snapshot_at = _now()
|
||||
|
||||
if order.shop:
|
||||
@@ -3281,16 +3536,22 @@ def create_order(
|
||||
"required_points": order.required_points,
|
||||
"bind_game_account": order.bind_game_account or "",
|
||||
"bind_game_role": order.bind_game_role or "",
|
||||
"nickname": order.nickname or "",
|
||||
"home_images": len(order.home_images or []),
|
||||
"draft": bool(draft),
|
||||
# 这个虎牙号还挂在哪些单上(允许同一份 CK 服务多单,留个痕)
|
||||
"uid_reused_in": uid_reused,
|
||||
},
|
||||
)
|
||||
if probe_result is not None and probe_result.ok:
|
||||
if probe_result is not None:
|
||||
# 结论仍然走 set_ck_status 这个唯一写入口(审计与 ck_checked_at 不会漂)。
|
||||
set_ck_status(
|
||||
db, current, order, CK_VALID, message=probe_result.message, commit=False
|
||||
db,
|
||||
current,
|
||||
order,
|
||||
CK_VALID if probe_result.ok else CK_INVALID,
|
||||
message=probe_result.message,
|
||||
commit=False,
|
||||
)
|
||||
_maybe_advance_to_recharge(db, current, order)
|
||||
if commit:
|
||||
|
||||
Reference in New Issue
Block a user