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:
@@ -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"`)—— 它没有独立价值。
|
||||
"""
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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 ""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user