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 ""
),
}