feat(huya): 批量查询拆成「查积分」与「查兑换记录」两个按钮

前端工具栏原来一个按钮同时查两件事,拆开后每次只查一半,所以重点在
「没查的那一半不能被当成没查到」:

- 接口加 scope(both / score / records),默认 both 保持原行为;
  只查积分时不产生判定(空串「未判定」,与 unknown「无法判定」分开),
  也**不会**自动改业务状态(连判定都没有,凭什么改)。
- 列表的积分与判定各取「最近一条查到过它的」快照:点「查积分」不再把
  整页判定徽标冲成未判定,点「查兑换记录」也不再清空积分;但新的
  unknown 仍会盖掉旧的 success(抖动结果不许藏)。
- 结果弹窗 / 积分列 Tooltip / 生命周期时间线都按 scope 显示,
  不再把「本次没查」显示成「积分 -」「兑换记录 0 条」。
- 新增 12 例测试(TestCollabActivityScopeSplit),并做了 4 次反向验证
  (去掉判定分支 / 判定跟随最新 / 积分跟随最新 / 删掉响应字段),全部确认变红。

顺带修掉文档漂移:AGENTS.md 的迁移 head 早就过期(main 实际
20261016_0059、dev 20261008_0053),并补上「两侧已分叉成两个 head、
合并 dev 前必须先 alembic merge」这条;设计文档补 §11.9。
This commit is contained in:
yml2213
2026-10-17 23:32:51 +08:00
parent b47dde47e9
commit c441b6c663
10 changed files with 722 additions and 121 deletions
+4 -2
View File
@@ -1204,8 +1204,10 @@ class HuyaExternalOrderMetric(Base):
1. **只追加、不更新、不删除**(快照语义):历史要能回放,改一行就等于毁证;
2. **不存凭据**:`raw` 只放积分响应与兑换记录明细(里面没有 cookie),
凭据永远只待在 `huya_external_orders.cookie`;
3. `verdict` 三态:`success`(兑换记录里有这一单的礼包)/ `failed`(接口通了但没记录)/
`unknown`(接口没通或没可比的礼包名),**只有 success / failed 会去改业务状态**;
3. `verdict`:`success`(兑换记录里有这一单的礼包)/ `failed`(接口通了但没记录)/
`unknown`(查了兑换记录,但接口没通或没可比的礼包名)/**空串**(本次根本没查兑换记录,
2026-10-17 拆出「只查积分」入口后才有),**只有 success / failed 会去改业务状态**;
`raw.scope` 记着这条快照查了哪一半(`both` / `score` / `records`),前端据此取舍。
4. 订单删掉时这条也跟着删(`ondelete="CASCADE"`)—— 它没有独立价值。
"""
+15 -8
View File
@@ -77,12 +77,12 @@ def _http_error(exc: collab.CollabError) -> HTTPException:
def _serialize_one(db: Session, order: HuyaExternalOrder) -> dict:
clusters = collab.buyer_clusters(db, [order])
metrics = collab.latest_metrics(db, [order])
views = collab.order_metric_views(db, [order])
return collab.serialize_order(
order,
users=collab.users_by_id(db, [order]),
buyer_cluster=clusters.get(int(order.id)),
last_metric=metrics.get(int(order.id)),
metric_view=views.get(int(order.id)),
)
@@ -246,14 +246,14 @@ def list_external_orders(
names = collab.users_by_id(db, rows)
# 「同一买家的其它单」+「最近一次积分/兑换快照」整页一次算完,别按行发 SQL
clusters = collab.buyer_clusters(db, rows)
metrics = collab.latest_metrics(db, rows)
views = collab.order_metric_views(db, rows)
return {
"items": [
collab.serialize_order(
row,
users=names,
buyer_cluster=clusters.get(int(row.id)),
last_metric=metrics.get(int(row.id)),
metric_view=views.get(int(row.id)),
)
for row in rows
],
@@ -383,14 +383,18 @@ def batch_query_external_order_activity(
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""勾选多行查询积分 + 兑换记录(**串行带间隔**,防风控)。
"""勾选多行查积分 / 兑换记录(**串行带间隔**,防风控)。
每行都会落一条生命周期快照;「已上脚本」的单按查询结果自动改状态
(查到礼包 → 抢购成功;没查到 → 抢购失败),其它状态只记录不动状态。
``scope`` 决定查哪一半(前端是两个独立按钮,见 2026-10-17 需求):
``both`` 两个接口都查、``score`` 只查积分、``records`` 只查兑换记录。
每行都会落一条生命周期快照;**只有本次查了兑换记录**且结论明确时,
「已上脚本」的单才会自动改状态(查到礼包 → 抢购成功;没查到 → 抢购失败),
其它情况只记录不动状态 —— 只查积分时连判定都没有,更不该改状态。
"""
try:
orders = collab.list_orders_by_ids(db, req.ids)
result = collab.query_activity_batch(db, current, orders)
result = collab.query_activity_batch(db, current, orders, scope=req.scope)
except collab.CollabError as exc:
raise _http_error(exc) from exc
for item in result["results"]:
@@ -399,6 +403,9 @@ def batch_query_external_order_activity(
)
auto = item.get("auto_status") or ""
item["auto_status_label"] = collab.ORDER_STATUS_LABELS.get(auto, auto)
# 空判定(只查积分)= 「未判定」,与「无法判定」区分开(见 VERDICT_LABELS)
verdict = item.get("verdict") or ""
item["verdict_label"] = collab.VERDICT_LABELS.get(verdict, verdict)
_publish_change(current, scope="orders", action="orders.activity_query")
return result
+29 -6
View File
@@ -1303,9 +1303,17 @@ class HuyaExternalOrderStatusBatchOut(BaseModel):
class HuyaExternalOrderActivityQueryRequest(BaseModel):
"""勾选多行,一次查这些行的积分 + 兑换记录(一次最多 50 条,串行带间隔)。"""
"""勾选多行查积分 / 兑换记录(一次最多 50 条,串行带间隔)。
``scope``(2026-10-17 用户要求把批量按钮拆成两个):
- ``both``(默认,保持原行为):两个接口都查;
- ``score``:只查积分 —— 没有兑换记录就没有判定,**不会**自动改业务状态;
- ``records``:只查兑换记录 —— 积分不更新,判定与自动改状态照旧。
"""
ids: list[int] = Field(default_factory=list)
scope: str = "both"
class HuyaExternalOrderCkExportRequest(BaseModel):
@@ -1356,19 +1364,22 @@ class HuyaCollabMetricMatchedOut(BaseModel):
class HuyaCollabMetricOut(BaseModel):
"""一条「账号全生命周期」快照(积分 + 兑换记录 + 判定)。"""
"""一条「账号全生命周期」快照(积分 / 兑换记录 + 判定)。"""
id: int
order_id: int
order_no: str = ""
uid: str = ""
sid: int | None = None
# 这条快照查了哪一半(both / score / records):前端据此决定显示哪几段
scope: str = "both"
score: int | None = None
gained_score: int | None = None
used_score: int | None = None
records_total: int = 0
records_matched: int = 0
verdict: str = "unknown"
# 空串 = 本次没查兑换记录(只查积分),与 unknown(查了判不了)不同
verdict: str = ""
verdict_label: str = ""
message: str = ""
source: str = "manual"
@@ -1384,6 +1395,10 @@ class HuyaExternalOrderActivityItemOut(BaseModel):
ok: bool = False
order_id: int
order_no: str = ""
# 本次查的是哪一半(``both`` / ``score`` / ``records``)
scope: str = ""
scope_label: str = ""
# 只查积分时为**空串**(未判定),不是 ``unknown``(查了但判不了)
verdict: str = "unknown"
verdict_label: str = ""
score: int | None = None
@@ -1400,13 +1415,17 @@ class HuyaExternalOrderActivityItemOut(BaseModel):
class HuyaExternalOrderActivityQueryOut(BaseModel):
"""批量查询汇总:``success + failed + unknown = total``。"""
"""批量查询汇总:``success + failed + unknown + not_judged = total``。"""
total: int
success: int
failed: int
unknown: int
unknown: int = 0
# 本次没查兑换记录、因此没有判定结论的行数(只查积分时全落这里)
not_judged: int = 0
auto_changed: int = 0
scope: str = ""
scope_label: str = ""
results: list[HuyaExternalOrderActivityItemOut] = Field(default_factory=list)
@@ -1489,11 +1508,15 @@ class HuyaExternalOrderOut(BaseModel):
buyer_cluster: HuyaCollabBuyerClusterOut | None = None
# 最近一次「积分 / 兑换记录」快照(完整时间线走 GET /{id}/metrics)
# 积分与判定是**分开**取最近一条查到过它的:只查积分的那次不产生判定,反之亦然
# (2026-10-17 拆分查询入口,见 services/huya_collab_service.order_metric_views)
last_score: int | None = None
last_metric_at: datetime | None = None
last_metric_message: str = ""
last_verdict: str = ""
last_verdict_label: str = ""
last_metric_message: str = ""
last_verdict_at: datetime | None = None
last_verdict_message: str = ""
class HuyaExternalOrderListOut(BaseModel):
+213 -43
View File
@@ -1136,11 +1136,33 @@ ACTIVITY_QUERY_TIMEOUT_SECONDS = 15.0
VERDICT_SUCCESS = "success"
VERDICT_FAILED = "failed"
VERDICT_UNKNOWN = "unknown"
#: 「本次**没查**兑换记录」的判定值:**空串**。
#: 必须与 ``unknown``(查了、但接口没通/判不了)分开 —— 列表上的判定徽标会跳过空值,
#: 否则点一次「查积分」就会把整页的徽标冲成「无法判定」,把已有结论抹掉。
VERDICT_NOT_QUERIED = ""
VERDICT_LABELS = {
VERDICT_SUCCESS: "兑换成功",
VERDICT_FAILED: "兑换失败",
VERDICT_UNKNOWN: "无法判定",
# 空判定 = 本次没查兑换记录(拆分查询入口后才有),与「无法判定」必须分开
VERDICT_NOT_QUERIED: "未判定",
}
# 「积分 / 兑换记录」的查询范围(2026-10-17 用户要求:批量按钮拆成两个)。
# 只查一半时,**没查的那一半不写值、也不判定** —— 不许拿「没查」冒充「没有」。
ACTIVITY_SCOPE_BOTH = "both"
ACTIVITY_SCOPE_SCORE = "score"
ACTIVITY_SCOPE_RECORDS = "records"
ACTIVITY_SCOPES: tuple[str, ...] = (
ACTIVITY_SCOPE_BOTH,
ACTIVITY_SCOPE_SCORE,
ACTIVITY_SCOPE_RECORDS,
)
ACTIVITY_SCOPE_LABELS = {
ACTIVITY_SCOPE_BOTH: "积分 + 兑换记录",
ACTIVITY_SCOPE_SCORE: "积分",
ACTIVITY_SCOPE_RECORDS: "兑换记录",
}
# metric.raw 里最多留多少条兑换记录(避免 JSON 列被几百条记录撑爆)
@@ -1215,7 +1237,11 @@ def _extract_records(resp: Any) -> tuple[list[dict[str, Any]], str]:
def serialize_metric(
metric: HuyaExternalOrderMetric, *, users: Mapping[int, str] | None = None
) -> dict:
"""一条生命周期快照的输出(时间线用)。"""
"""一条生命周期快照的输出(时间线用)。
``scope`` 说明这条快照当时查了哪一半 —— 前端的取舍靠它,别靠"字段是不是空"猜:
只查积分的那条积分有值、兑换记录是 0 条,不能拿 ``records_total == 0`` 当"没兑换到"。
"""
names = users or {}
raw = metric.raw if isinstance(metric.raw, dict) else {}
matched = raw.get("matched") if isinstance(raw.get("matched"), list) else []
@@ -1225,12 +1251,14 @@ def serialize_metric(
"order_no": metric.order_no or "",
"uid": metric.uid or "",
"sid": metric.sid,
"scope": str(raw.get("scope") or ACTIVITY_SCOPE_BOTH),
"score": metric.score,
"gained_score": metric.gained_score,
"used_score": metric.used_score,
"records_total": int(metric.records_total or 0),
"records_matched": int(metric.records_matched or 0),
"verdict": metric.verdict or VERDICT_UNKNOWN,
# 空 = 本次没查兑换记录(≠「无法判定」:那个是查了但判不了)
"verdict": metric.verdict or VERDICT_NOT_QUERIED,
"verdict_label": VERDICT_LABELS.get(metric.verdict, metric.verdict or ""),
"message": metric.message or "",
"source": metric.source or "manual",
@@ -1263,28 +1291,87 @@ def list_metrics(
)
@dataclasses.dataclass(frozen=True)
class OrderMetricView:
"""列表「积分」列的三条线索(见 :func:`order_metric_views`)。
``score`` / ``verdict`` 各自可能比 ``latest`` 旧:只查积分的那次不产生判定,
只查兑换记录的那次不产生积分 —— 谁都没产出的那次就不该把对方从列表上抹掉。
"""
latest: HuyaExternalOrderMetric | None = None
score: HuyaExternalOrderMetric | None = None
verdict: HuyaExternalOrderMetric | None = None
def _latest_metric_ids(db: Session, ids: Sequence[int], *filters: Any) -> list[int]:
"""这批行各自「最近一条」快照的主键(可选附加过滤条件)。"""
if not ids:
return []
newest = (
db.query(func.max(HuyaExternalOrderMetric.id).label("metric_id"))
.filter(HuyaExternalOrderMetric.order_id.in_(list(ids)), *filters)
.group_by(HuyaExternalOrderMetric.order_id)
.subquery()
)
return [row[0] for row in db.query(newest.c.metric_id).all() if row[0] is not None]
def _metrics_by_id(
db: Session, metric_ids: Sequence[int]
) -> dict[int, HuyaExternalOrderMetric]:
if not metric_ids:
return {}
rows = (
db.query(HuyaExternalOrderMetric)
.filter(HuyaExternalOrderMetric.id.in_(list(metric_ids)))
.all()
)
return {int(row.order_id): row for row in rows}
def latest_metrics(
db: Session, orders: Sequence[HuyaExternalOrder]
) -> dict[int, HuyaExternalOrderMetric]:
"""一次查出这批行各自「最近一条」快照(列表要显示最新积分,别按行发 SQL)。"""
ids = [int(order.id) for order in orders]
return _metrics_by_id(db, _latest_metric_ids(db, ids))
def order_metric_views(
db: Session, orders: Sequence[HuyaExternalOrder]
) -> dict[int, OrderMetricView]:
"""列表「积分」列要显示的三条线索(整页一次算完,别按行发 SQL)。
2026-10-17 拆出「查积分 / 查兑换记录」两个入口之后,**一次查询只写一半**:
只查积分的那条 ``verdict`` 是空的、只查兑换记录的那条 ``score`` 是空的。所以:
- ``latest`` —— 最近一条(列上的「最后查询时间」与结论文字);
- ``score`` —— 最近一条**查过积分**的:只查兑换记录时不能让积分显示变成 ``-``;
- ``verdict`` —— 最近一条**有判定结论**的(``success`` / ``failed`` / ``unknown``):
只查积分时不能把已有的判定徽标冲成「无法判定」。
注意 ``unknown``(查了但判不了)**要算结论**,否则接口抖动时会一直挂着旧徽标。
三条线索最多各发一次分组查询;都没有快照时返回空 dict。
"""
ids = [int(order.id) for order in orders]
if not ids:
return {}
newest = (
db.query(func.max(HuyaExternalOrderMetric.id).label("metric_id"))
.filter(HuyaExternalOrderMetric.order_id.in_(ids))
.group_by(HuyaExternalOrderMetric.order_id)
.subquery()
latest = _metrics_by_id(db, _latest_metric_ids(db, ids))
scored = _metrics_by_id(
db, _latest_metric_ids(db, ids, HuyaExternalOrderMetric.score.isnot(None))
)
metric_ids = [row[0] for row in db.query(newest.c.metric_id).all()]
if not metric_ids:
return {}
rows = (
db.query(HuyaExternalOrderMetric)
.filter(HuyaExternalOrderMetric.id.in_(metric_ids))
.all()
judged = _metrics_by_id(
db, _latest_metric_ids(db, ids, HuyaExternalOrderMetric.verdict != "")
)
return {int(row.order_id): row for row in rows}
views: dict[int, OrderMetricView] = {}
for order_id in {*latest, *scored, *judged}:
views[order_id] = OrderMetricView(
latest=latest.get(order_id),
score=scored.get(order_id),
verdict=judged.get(order_id),
)
return views
def query_order_activity(
@@ -1292,13 +1379,23 @@ def query_order_activity(
current: User | None,
order: HuyaExternalOrder,
*,
scope: str = ACTIVITY_SCOPE_BOTH,
client: Any | None = None,
source: str = "manual",
commit: bool = True,
) -> dict:
"""查一次这个账号的**积分 + 兑换记录**,落一条生命周期快照,必要时自动改状态。
"""查一次这个账号的**积分 / 兑换记录**,落一条生命周期快照,必要时自动改状态。
判定规则(2026-10-16 用户口径):
``scope`` 决定这次查哪一半(2026-10-17 用户要求把批量按钮拆成两个):
- ``both``(默认,保持原行为):两个接口都打,判定按兑换记录来;
- ``score``:**只打积分接口**。没查兑换记录,所以 ``verdict`` 是空串
(``VERDICT_NOT_QUERIED``,不是 ``unknown``)——「没查」和「查了判不了」是两回事,
列表上的判定徽标只认有结论的,否则点一次「查积分」会把整页徽标冲成「无法判定」。
**这种查询永远不会自动改业务状态**(结论都没有,凭什么改);
- ``records``:**只打兑换记录接口**。积分不写(``score`` 留空),判定与自动改状态照旧。
判定规则(2026-10-16 用户口径,仅当本次查了兑换记录时):
| 情况 | verdict | 业务状态 |
| --- | --- | --- |
@@ -1310,6 +1407,11 @@ def query_order_activity(
被跳过时结论里会写清楚原因。每次调用都写一条 metric(哪怕接口失败),
「积分变化 + 兑换记录」两条线索合起来才看得清有没有真的抢到。
"""
if scope not in ACTIVITY_SCOPES:
raise CollabError(f"未知的查询范围:{scope}", code="activity_scope_unknown")
want_score = scope in (ACTIVITY_SCOPE_BOTH, ACTIVITY_SCOPE_SCORE)
want_records = scope in (ACTIVITY_SCOPE_BOTH, ACTIVITY_SCOPE_RECORDS)
if order.status == STATUS_VOID:
raise CollabError("已作废的单子不查积分/兑换记录", code="void_order")
uid = str(order.uid or "").strip()
@@ -1322,24 +1424,46 @@ def query_order_activity(
score_resp: Any = None
records_resp: Any = None
errors: list[str] = []
try:
score_resp = activity_client.query_user_score(
uid=int(uid), cookie=cookie, sid=sid, timeout=ACTIVITY_QUERY_TIMEOUT_SECONDS
)
except Exception as exc: # noqa: BLE001 一个接口炸了不影响另一个
errors.append(f"积分接口异常:{type(exc).__name__}: {_clip(exc)}")
try:
records_resp = activity_client.get_user_prize_records(
uid=int(uid), cookie=cookie, sid=sid, timeout=ACTIVITY_QUERY_TIMEOUT_SECONDS
)
except Exception as exc: # noqa: BLE001
errors.append(f"兑换记录接口异常:{type(exc).__name__}: {_clip(exc)}")
if want_score:
try:
score_resp = activity_client.query_user_score(
uid=int(uid),
cookie=cookie,
sid=sid,
timeout=ACTIVITY_QUERY_TIMEOUT_SECONDS,
)
except Exception as exc: # noqa: BLE001 一个接口炸了不影响另一个
errors.append(f"积分接口异常:{type(exc).__name__}: {_clip(exc)}")
if want_records:
try:
records_resp = activity_client.get_user_prize_records(
uid=int(uid),
cookie=cookie,
sid=sid,
timeout=ACTIVITY_QUERY_TIMEOUT_SECONDS,
)
except Exception as exc: # noqa: BLE001
errors.append(f"兑换记录接口异常:{type(exc).__name__}: {_clip(exc)}")
score, gained, used, score_msg = _extract_score(score_resp)
records, records_msg = _extract_records(records_resp)
score, gained, used, score_msg = (
_extract_score(score_resp) if want_score else (None, None, None, "")
)
records, records_msg = _extract_records(records_resp) if want_records else ([], "")
matched = gift_matches_records(records, order.gift_name)
if not str(order.gift_name or "").strip():
if not want_records:
# 只查积分:兑换记录一个字节都没查 —— 不许按"记录是空的"判成「兑换失败」,
# 也不许自动改状态。verdict 用空串表示「本次未判定」。
# 结论文字写**积分本身**(别写"本次只查了积分"这种每行重复的套话,
# 批量查询时那会把弹窗刷成一屏废话)。
verdict = VERDICT_NOT_QUERIED
if score_msg:
message = score_msg
elif score is not None:
message = f"积分 {score}(累计获得 {gained} / 已消耗 {used})"
else:
message = "没取到积分,本次也不查兑换记录"
elif not str(order.gift_name or "").strip():
verdict = VERDICT_UNKNOWN
message = "这单没填礼包名,没法跟兑换记录比对"
elif records_msg or errors:
@@ -1380,6 +1504,7 @@ def query_order_activity(
raw: dict[str, Any] = {
"verdict": verdict,
"scope": scope,
"score_available": score,
"score_gained": gained,
"score_used": used,
@@ -1423,6 +1548,7 @@ def query_order_activity(
"uid": uid,
"sid": sid,
"source": source,
"scope": scope,
"score": score,
"records_total": len(records),
"records_matched": len(matched),
@@ -1435,11 +1561,18 @@ def query_order_activity(
db.refresh(order)
db.refresh(metric)
return {
"ok": verdict != VERDICT_UNKNOWN,
# `ok` = 「这次要查的东西查到没有」:查了兑换记录就看判定,只查积分就看积分接口通没通
"ok": (
verdict not in (VERDICT_UNKNOWN, VERDICT_NOT_QUERIED)
if want_records
else not score_msg
),
"order_id": int(order.id),
"order_no": order.order_no,
"uid": uid,
"sid": sid,
"scope": scope,
"scope_label": ACTIVITY_SCOPE_LABELS.get(scope, scope),
"verdict": verdict,
"verdict_label": VERDICT_LABELS.get(verdict, verdict),
"score": score,
@@ -1461,10 +1594,18 @@ def query_activity_batch(
current: User | None,
orders: Sequence[HuyaExternalOrder],
*,
scope: str = ACTIVITY_SCOPE_BOTH,
client: Any | None = None,
interval_seconds: float = PROBE_BATCH_INTERVAL_SECONDS,
) -> dict:
"""勾选批量查询积分 / 兑换记录:**串行 + 间隔**(防风控),单条失败不影响其它。"""
"""勾选批量查询:**串行 + 间隔**(防风控),单条失败不影响其它。
``scope`` 与 :func:`query_order_activity` 一致(``both`` / ``score`` / ``records``):
只查积分时不会有判定结论,所以汇总里那些行进 ``not_judged``,
**不计入 unknown**(unknown 的含义是「查了但判不了」,两者不能混)。
"""
if scope not in ACTIVITY_SCOPES:
raise CollabError(f"未知的查询范围:{scope}", code="activity_scope_unknown")
rows = list(orders)
if not rows:
raise CollabError("请先勾选要查询的行", code="activity_batch_empty")
@@ -1482,7 +1623,13 @@ def query_activity_batch(
with db.begin_nested():
results.append(
query_order_activity(
db, current, order, client=client, source="batch", commit=False
db,
current,
order,
scope=scope,
client=client,
source="batch",
commit=False,
)
)
except CollabError as exc:
@@ -1491,6 +1638,8 @@ def query_activity_batch(
"ok": False,
"order_id": int(order.id),
"order_no": order.order_no,
"scope": scope,
"scope_label": ACTIVITY_SCOPE_LABELS.get(scope, scope),
"verdict": VERDICT_UNKNOWN,
"verdict_label": VERDICT_LABELS[VERDICT_UNKNOWN],
"message": exc.detail,
@@ -1505,13 +1654,19 @@ def query_activity_batch(
valid = [item for item in results if item.get("verdict") == VERDICT_SUCCESS]
failed = [item for item in results if item.get("verdict") == VERDICT_FAILED]
unknown = [item for item in results if item.get("verdict") == VERDICT_UNKNOWN]
not_judged = [
item for item in results if item.get("verdict") == VERDICT_NOT_QUERIED
]
auto_changed = [item for item in results if item.get("auto_status")]
return {
"total": len(results),
"success": len(valid),
"failed": len(failed),
"unknown": len(unknown),
"not_judged": len(not_judged),
"auto_changed": len(auto_changed),
"scope": scope,
"scope_label": ACTIVITY_SCOPE_LABELS.get(scope, scope),
"results": results,
}
@@ -2455,6 +2610,7 @@ def serialize_order(
users: Mapping[int, str] | None = None,
buyer_cluster: Mapping[str, Any] | None = None,
last_metric: HuyaExternalOrderMetric | None = None,
metric_view: OrderMetricView | None = None,
) -> dict:
"""把行对象转成接口输出。
@@ -2462,13 +2618,21 @@ def serialize_order(
全部失效(order_site 踩过「存下来的签名链接过期后到处裂图」)。
``buyer_cluster`` = 同一买家的其它单(见 :func:`buyer_clusters`),
``last_metric`` = 最近一次「积分 / 兑换记录」快照(见 :func:`latest_metrics`),
``metric_view`` = 最近一次「积分 / 兑换记录」快照的三条线索(见 :func:`order_metric_views`),
都由调用方传进来(列表是一次查询算出整页的,别在这里按行查)。
``last_metric`` 是兼容入口:只传它时三条线索都取这一条。
"""
names = users or {}
bind_images = images_of(order, "bind_images")
home_images = images_of(order, "home_images")
view = metric_view or OrderMetricView(
latest=last_metric, score=last_metric, verdict=last_metric
)
latest_metric = view.latest
score_metric = view.score or view.latest
verdict_metric = view.verdict or view.latest
def urls(keys: list[str], variant: str = "") -> list[str]:
return [build_file_url(key, variant) for key in keys]
@@ -2527,16 +2691,22 @@ def serialize_order(
"updated_at": order.updated_at,
# 同一买家的其它单(软识别,只读派生;没有就是 null)
"buyer_cluster": dict(buyer_cluster) if buyer_cluster else None,
# 最近一次「积分 / 兑换记录」快照(生命周期时间线在详情里,列表只带最新一条)
"last_score": last_metric.score if last_metric else None,
"last_metric_at": last_metric.created_at if last_metric else None,
"last_verdict": (last_metric.verdict or "") if last_metric else "",
# 最近一次「积分 / 兑换记录」快照(生命周期时间线在详情里,列表只带这几条线索)
# 积分与判定分别取「最近一条查到过它的」:拆分两个查询入口后,一次只写一半,
# 不能让「只查积分」把判定徽标抹掉(反之亦然)。
"last_score": score_metric.score if score_metric else None,
"last_metric_at": latest_metric.created_at if latest_metric else None,
"last_metric_message": (latest_metric.message or "") if latest_metric else "",
"last_verdict": (verdict_metric.verdict or "") if verdict_metric else "",
"last_verdict_label": (
VERDICT_LABELS.get(last_metric.verdict, last_metric.verdict or "")
if last_metric
VERDICT_LABELS.get(verdict_metric.verdict, verdict_metric.verdict or "")
if verdict_metric
else ""
),
"last_metric_message": (last_metric.message or "") if last_metric else "",
"last_verdict_at": verdict_metric.created_at if verdict_metric else None,
"last_verdict_message": (
(verdict_metric.message or "") if verdict_metric else ""
),
}
+9 -3
View File
@@ -34,6 +34,7 @@ import type {
HuyaCollabAuditItem,
HuyaCollabActivityBatchResult,
HuyaCollabActivityItem,
HuyaCollabActivityScope,
HuyaCollabCkExportResult,
HuyaCollabCkPreview,
HuyaCollabCkRequest,
@@ -271,11 +272,16 @@ export const huyaApi = {
api.post<HuyaCollabActivityItem, HuyaCollabActivityItem>(
`/huya/external-orders/${id}/activity/query`,
),
/** 勾选多行查询积分 + 兑换记录(串行带间隔) */
batchQueryCollabOrderActivity: (ids: number[]) =>
/**
* 勾选多行查积分 / 查兑换记录(串行带间隔)。
*
* `scope` 决定查哪一半(页面上是两个独立按钮):`score` 只查积分、
* `records` 只查兑换记录、`both` 两个都查(默认,保持旧行为)。
*/
batchQueryCollabOrderActivity: (ids: number[], scope: HuyaCollabActivityScope = 'both') =>
api.post<HuyaCollabActivityBatchResult, HuyaCollabActivityBatchResult>(
'/huya/external-orders/activity/query',
{ ids },
{ ids, scope },
),
/** 这一单的「账号全生命周期」时间线(每次查询的快照,新→旧) */
collabOrderMetrics: (id: number) =>
+31 -6
View File
@@ -1274,12 +1274,20 @@ export interface HuyaCollabOrder {
* 只有真的 ≥2 单才有值,否则是 null。
*/
buyer_cluster: HuyaCollabBuyerCluster | null;
/** 最近一次「查积分 + 兑换记录」的快照(完整时间线走 GET /{id}/metrics) */
/**
* 最近一次「查积分 / 查兑换记录」的快照(完整时间线走 GET /{id}/metrics)。
*
* 拆分两个查询入口后,积分与判定**各自取最近一条查到过它的**:
* 只查积分的那次不产生判定,只查兑换记录的那次不产生积分。
*/
last_score: number | null;
last_metric_at: string | null;
last_metric_message: string;
last_verdict: HuyaCollabVerdict | '';
last_verdict_label: string;
last_metric_message: string;
/** 那条判定是什么时候查出来的(与 `last_metric_at` 可能不是同一次) */
last_verdict_at: string | null;
last_verdict_message: string;
}
/** 「同一买家的其它单」里的一条 */
@@ -1307,6 +1315,13 @@ export interface HuyaCollabBuyerCluster {
/** 系统与接口的判定结论(人工不写,只读) */
export type HuyaCollabVerdict = 'success' | 'failed' | 'unknown';
/**
* 本次查询查了哪一半(两个批量按钮各用一个):
* `both` 两个接口都查、`score` 只查积分、`records` 只查兑换记录。
* 只查积分时没有判定结论,条目的 `verdict` 是**空串**(不是 `unknown`)。
*/
export type HuyaCollabActivityScope = 'both' | 'score' | 'records';
/** 「账号全生命周期」里匹配到的兑换记录 */
export interface HuyaCollabMetricMatched {
prize_name: string;
@@ -1316,7 +1331,7 @@ export interface HuyaCollabMetricMatched {
exchange_time: number | null;
}
/** 一次「查积分 + 兑换记录」的快照(只追加,不更新) */
/** 一次「查积分 / 兑换记录」的快照(只追加,不更新) */
export interface HuyaCollabMetric {
id: number;
order_id: number;
@@ -1328,7 +1343,10 @@ export interface HuyaCollabMetric {
used_score: number | null;
records_total: number;
records_matched: number;
verdict: HuyaCollabVerdict;
/** 这条快照查了哪一半:时间线据此决定显示「积分」还是「本次没查积分」 */
scope: HuyaCollabActivityScope;
/** 只查积分的那条快照是**空串**(本次没查兑换记录,不算「无法判定」) */
verdict: HuyaCollabVerdict | '';
verdict_label: string;
message: string;
/** manual(人工点)/ batch(勾选批量) */
@@ -1344,7 +1362,10 @@ export interface HuyaCollabActivityItem {
ok: boolean;
order_id: number;
order_no: string;
verdict: HuyaCollabVerdict;
/** 本次查的是哪一半(只查积分时 verdict 是空串) */
scope: HuyaCollabActivityScope | '';
scope_label: string;
verdict: HuyaCollabVerdict | '';
verdict_label: string;
score: number | null;
gained_score: number | null;
@@ -1359,13 +1380,17 @@ export interface HuyaCollabActivityItem {
status_label: string;
}
/** 批量查询汇总 */
/** 批量查询汇总(`success + failed + unknown + not_judged = total`) */
export interface HuyaCollabActivityBatchResult {
total: number;
success: number;
failed: number;
unknown: number;
/** 本次没查兑换记录、因此没有判定结论的行数(只查积分时全落这里) */
not_judged: number;
auto_changed: number;
scope: HuyaCollabActivityScope | '';
scope_label: string;
results: HuyaCollabActivityItem[];
}
+109 -51
View File
@@ -15,12 +15,13 @@ import {
import type { TableProps } from 'antd';
import {
CheckCircleOutlined, CloudUploadOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileAddOutlined,
FileExcelOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined,
StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined,
FileExcelOutlined, GiftOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined,
ShopOutlined, StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined,
} from '@ant-design/icons';
import {
fileApi,
huyaApi,
type HuyaCollabActivityScope,
type HuyaCollabAuditItem,
type HuyaCollabCkExportResult,
type HuyaCollabCkPreview,
@@ -788,21 +789,41 @@ export default function HuyaCollabPage() {
}
};
/** 勾选批量查询(串行带间隔,防风控) */
const queryActivitySelected = async () => {
/**
* 勾选批量查询(串行带间隔,防风控)。
*
* `scope` 决定查哪一半 —— 界面上是**两个独立按钮**(2026-10-17 用户要求):
* 「查积分」只打积分接口,不会产生判定、也**不会自动改状态**;
* 「查兑换记录」只打兑换记录接口,判定与自动改状态照旧。
*/
const queryActivitySelected = async (scope: HuyaCollabActivityScope) => {
if (selectedIds.length === 0) return;
setQuerying(true);
try {
const result = await huyaApi.batchQueryCollabOrderActivity(selectedIds);
const result = await huyaApi.batchQueryCollabOrderActivity(selectedIds, scope);
const scoreScope = scope === 'score';
const missed = result.results.filter((item) => !item.ok).length;
modal.info({
title: `查询完成:成功 ${result.success} / 失败 ${result.failed} / 无法判定 ${result.unknown}`
+ (result.auto_changed ? `(${result.auto_changed} 条按结果自动改状态)` : ''),
title: scoreScope
? `查积分完成:${result.total} 条` + (missed ? `(${missed} 条没拿到积分)` : '')
: `查兑换记录完成:成功 ${result.success} / 失败 ${result.failed} / 无法判定 ${result.unknown}`
+ (result.auto_changed ? `(${result.auto_changed} 条按结果自动改状态)` : ''),
width: 760,
content: (
<Space direction="vertical" style={{ width: '100%' }}>
{scoreScope && (
<Text type="secondary" style={{ fontSize: 12 }}>
这一趟只查积分,没有查兑换记录,所以不会改业务状态(判定那栏保持上一次的结论)。
</Text>
)}
{result.results.map((item) => (
<Text key={item.order_id} type={item.verdict === 'failed' ? 'danger' : undefined}>
{item.order_no}:{item.verdict_label} · {item.message}
<Text
key={item.order_id}
type={!item.ok ? 'danger' : item.verdict === 'failed' ? 'danger' : undefined}
>
{item.order_no}:
{/* 只查积分时 verdict 是空的(未判定),没必要每行都挂一个「未判定」标签 */}
{scoreScope ? item.message : `${item.verdict_label} · ${item.message}`}
{item.auto_status ? `;状态已改为「${item.auto_status_label}」` : ''}
</Text>
))}
@@ -1767,8 +1788,16 @@ export default function HuyaCollabPage() {
return <Text type="secondary" style={{ fontSize: 12 }}>还没查</Text>;
}
const verdict = VERDICT_META[row.last_verdict];
// 积分与判定可能是**两次不同的查询**(查积分 / 查兑换记录是两个按钮):
// 徽标保留上一次的判定结论,各自把自己的时间与结论写清楚,别让人以为徽标也是刚刚那次的。
const tip = [
`最新查询(${formatTime(row.last_metric_at)}):${row.last_metric_message || '—'}`,
verdict
? `判定(${formatTime(row.last_verdict_at)}):${row.last_verdict_message || '—'}`
: '',
].filter(Boolean).join('\n');
return (
<Tooltip title={`${row.last_metric_message || ''}(${formatTime(row.last_metric_at)})`}>
<Tooltip title={<span style={{ whiteSpace: 'pre-line' }}>{tip}</span>}>
<Space direction="vertical" size={0}>
<Text strong>{row.last_score ?? '-'}</Text>
{verdict && <Tag color={verdict.color} style={{ marginTop: 2 }}>{verdict.label}</Tag>}
@@ -1996,11 +2025,21 @@ export default function HuyaCollabPage() {
</Dropdown>
)}
{canEdit && (
<Button icon={<SyncOutlined />} disabled={selectedIds.length === 0 || busy}
loading={querying}
onClick={() => void queryActivitySelected()}>
批量查积分/兑换{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
// 查积分 / 查兑换记录**拆成两个按钮**(2026-10-17):查积分只写积分快照,
// 既不判定也不自动改状态;只有查兑换记录才产出判定并可能自动改状态。
// 合成一个按钮时「只想要积分」也会顺带把状态改了,两边互相干扰。
<>
<Button icon={<SyncOutlined />} disabled={selectedIds.length === 0 || busy}
loading={querying}
onClick={() => void queryActivitySelected('score')}>
批量查积分{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
<Button icon={<GiftOutlined />} disabled={selectedIds.length === 0 || busy}
loading={querying}
onClick={() => void queryActivitySelected('records')}>
批量查兑换记录{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
</>
)}
{canExportCk && (
// 导出 CK 到剪贴板(勾选几行就导几行;明文只在这个弹窗里出现)
@@ -2367,45 +2406,64 @@ export default function HuyaCollabPage() {
)}
<Timeline
pending={metricsLoading ? '查询中…' : undefined}
items={metrics.map((item) => ({
color: item.verdict === 'success' ? 'green' : item.verdict === 'failed' ? 'red' : 'gray',
children: (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Space size={6} wrap>
<Tag color={VERDICT_META[item.verdict]?.color}>{item.verdict_label}</Tag>
<Text strong>积分 {item.score ?? '-'}</Text>
items={metrics.map((item) => {
// 一次查询只查一半时,另一半**压根没查** —— 别把「没查」显示成「积分 -」「0 条」
const scope = item.scope || 'both';
const hasScore = scope !== 'records';
const hasRecords = scope !== 'score';
return {
color: item.verdict === 'success' ? 'green' : item.verdict === 'failed' ? 'red' : 'gray',
children: (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Space size={6} wrap>
<Tag color={VERDICT_META[item.verdict]?.color}>{item.verdict_label}</Tag>
{hasScore ? (
<>
<Text strong>积分 {item.score ?? '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
获得 {item.gained_score ?? '-'} / 消耗 {item.used_score ?? '-'}
</Text>
</>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>本次没查积分</Text>
)}
{hasRecords ? (
<Text type="secondary" style={{ fontSize: 12 }}>
兑换记录 {item.records_total} 条(命中 {item.records_matched})
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>本次没查兑换记录</Text>
)}
</Space>
{item.message && (
<Text type={item.verdict === 'failed' ? 'danger' : 'secondary'} style={{ fontSize: 12 }}>
{item.message}
</Text>
)}
{item.matched_preview.length > 0 && (
<Space direction="vertical" size={0}>
{item.matched_preview.map((matched, index) => (
<Text key={`${matched.order_id}-${index}`} style={{ fontSize: 12 }}>
<Text code style={{ fontSize: 12 }}>{matched.prize_name || '(无礼包名)'}</Text>
{' '}
<Text type="secondary" style={{ fontSize: 12 }}>
{matched.status_label}
{matched.order_id ? ` · 兑换单 ${matched.order_id}` : ''}
{matched.exchange_time ? ` · ${formatTime(new Date(matched.exchange_time * 1000).toISOString())}` : ''}
</Text>
</Text>
))}
</Space>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
获得 {item.gained_score ?? '-'} / 消耗 {item.used_score ?? '-'}
{' · '}兑换记录 {item.records_total} 条(命中 {item.records_matched})
{formatTime(item.created_at)} · {item.created_by_name || '系统'}
{' · '}{item.source === 'batch' ? '批量查询' : '手动查询'}
{' · '}{scope === 'both' ? '积分 + 兑换记录' : scope === 'score' ? '只查积分' : '只查兑换记录'}
</Text>
</Space>
{item.message && (
<Text type={item.verdict === 'failed' ? 'danger' : 'secondary'} style={{ fontSize: 12 }}>
{item.message}
</Text>
)}
{item.matched_preview.length > 0 && (
<Space direction="vertical" size={0}>
{item.matched_preview.map((matched, index) => (
<Text key={`${matched.order_id}-${index}`} style={{ fontSize: 12 }}>
<Text code style={{ fontSize: 12 }}>{matched.prize_name || '(无礼包名)'}</Text>
{' '}
<Text type="secondary" style={{ fontSize: 12 }}>
{matched.status_label}
{matched.order_id ? ` · 兑换单 ${matched.order_id}` : ''}
{matched.exchange_time ? ` · ${formatTime(new Date(matched.exchange_time * 1000).toISOString())}` : ''}
</Text>
</Text>
))}
</Space>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
{formatTime(item.created_at)} · {item.created_by_name || '系统'}
{' · '}{item.source === 'batch' ? '批量查询' : '手动查询'}
</Text>
</Space>
),
}))}
),
};
})}
/>
</Space>
)}