Skip to content

Commit 8dc5961

Browse files
committed
fix(growth): 接单受阻按原因归并 + 区分业务常态与真失败
新账号(17673045920,first_visit=true / level=0)的报告刷出 18 行噪音: 17 条 prerequisite not met: first_buddy + 1 条 task does not require acceptance + 1 条 no active buddy(HTTP 400)。实际只需做一件事——在客户端聊一次。 问题不在代码路径,而在**分类**:上游返回的这三类都不是"失败",却被当失败报了。 接单结果分三类(此前一律 FAILED): - `prerequisite not met: <code>` → 前置任务未完成。按原因归并成一条汇总 ("17 个任务需先完成「领取一只 Buddy(在客户端新建任务并发起对话)」"), 记 IDLE 不算失败,并给出用户该做什么的可读说明 - `task does not require acceptance` → 正常应答,不产生任何步骤 - 其余 → 逐条 FAILED(真需要人看的) 新增 GrowthStep.reportable:控制该步是否进「一行汇报」。汇报是给用户看的摘要, 必须只含能据此行动的信息——IDLE 里若有用户需要动手的事项(前置受阻、尚未领取 Buddy)必须显式带进汇报,否则用户只看到「接单完成 共 1 个」却不知道还有 17 个 被门住。 同类修正:`no active buddy`(400)是账号状态(新账号还没 Buddy)而非故障,记 IDLE; 它与 17 个任务的 first_buddy 是同一根因。 效果(真实账号实测): 旧: 18 行,其中 17 行同一条消息 新: 派 Buddy:尚未领取 Buddy(在客户端新建任务并发起对话); 接单受阻:17 个任务需先完成「领取一只 Buddy(…)」(在官方客户端操作后自动解除) 验证:974 测试 / 100% 行+分支覆盖;ruff、前端 104 测试通过。
1 parent 53a7751 commit 8dc5961

4 files changed

Lines changed: 262 additions & 15 deletions

File tree

‎TECHNICAL.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,21 @@ provider 在身份未知时返回空串(CB 的 `checkin_scope_key` 在 `accoun
312312
- 结论:`failed 且 gained=False` 才算整体失败。部分成功仍是成功——上游某个接口抖动
313313
不该让「今天领到 300 积分」变成一张红牌,否则定时任务天天报红,真故障被淹没
314314

315+
**接单失败要分三类,不能一律记 FAILED**(实测一个新账号的报告里刷出 17 条
316+
`prerequisite not met: first_buddy`,把「其实只需做一件事」淹没了):
317+
318+
| 上游返回 | 处置 |
319+
|---|---|
320+
| `prerequisite not met: <task_code>` | 前置任务未完成。**按原因归并成一条**并标 `reportable=True`(用户需要知道去做什么),记 IDLE 不算失败 |
321+
| `task does not require acceptance` | 正常应答(该任务不需要接单),**不产生任何步骤** |
322+
| 其余 | 逐条记 FAILED(真需要人看的失败) |
323+
324+
`GrowthStep.reportable` 控制该步是否进「一行汇报」:默认只收 DONE/FAILED,
325+
但 IDLE 里若有**用户需要动手**的事项(前置任务受阻、尚未领取 Buddy)必须显式
326+
置 True 带进汇报——否则用户只看到「接单完成 共 1 个」,看不到还有 17 个被门住。
327+
328+
同理,`no active buddy`(400)是账号状态(新账号还没 Buddy)而非故障,记 IDLE。
329+
315330
**任务契约是五态,不是三态**(2026-09 桌面端成长中心 H5 `growthSpace` chunk 读出,
316331
被上游改版坑过一次,勿按直觉回退):
317332

‎src/provider/base.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,20 @@ class StepStatus(StrEnum):
145145

146146
@dataclass(slots=True)
147147
class GrowthStep:
148-
"""成长中心一个子步骤的结果;detail 是给人看的一句中文。"""
148+
"""成长中心一个子步骤的结果;detail 是给人看的一句中文。
149+
150+
reportable 控制该步是否进「一行汇报」:汇报是用户看的摘要,必须只含
151+
用户**能据此行动**的信息(领到什么、哪里出错了、需要他去做什么)。
152+
默认只收 DONE 与 FAILED;IDLE 里若有需要用户动手的事项(如「N 个任务
153+
需先在客户端完成前置任务」),显式置 reportable=True 带进汇报——
154+
否则用户只会看到「接单完成 共 1 个」,看不到还有 17 个被门住。
155+
"""
149156

150157
name: str
151158
status: StepStatus
152159
detail: str = ""
153160
credit: float | None = None
161+
reportable: bool | None = None # None=按 status 判定(DONE/FAILED 进)
154162

155163

156164
@dataclass(slots=True)

‎src/provider/codebuddy/growth_runner.py‎

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,16 @@ async def _depart(self, credential: CodeBuddyCredential, result: GrowthResult) -
151151
location = locations[0]
152152
try:
153153
data = await self._client.depart(credential, location.id)
154+
except GrowthRejected as error:
155+
if _is_no_buddy(error):
156+
# 还没有 Buddy 时派出必然失败——这是账号状态而非故障,而且与其他
157+
# 17 个任务的 first_buddy 是同一个根因,说一遍就够(不刷屏、不算失败)
158+
result.steps.append(GrowthStep(
159+
"派 Buddy", StepStatus.IDLE, "尚未领取 Buddy(在客户端新建任务并发起对话)",
160+
reportable=True))
161+
return
162+
self._note_error(result, "派 Buddy", error)
163+
return
154164
except Exception as error: # noqa: BLE001
155165
self._note_error(result, "派 Buddy", error)
156166
return
@@ -183,9 +193,21 @@ async def _tasks(self, credential: CodeBuddyCredential,
183193

184194
async def _accept_pending(self, credential: CodeBuddyCredential, result: GrowthResult,
185195
tasks: list[GrowthTaskItem]) -> bool:
186-
"""接单:一批提交多个 task_code;逐条读 results,失败必须报出来。"""
196+
"""接单:一批提交多个 task_code,逐条读 results。
197+
198+
失败要分三类对待,**不是**一律记 FAILED:
199+
- `prerequisite not met: <code>`:前置任务未完成。这是常态(新账号全部任务都
200+
被 first_buddy 门住),而且**众多任务共享同一个前置条件**——逐条报会让报告
201+
变成十几行同样的噪音,掩盖「其实只需做一件事」。按原因归并成一条汇总。
202+
- `task does not require acceptance`:上游说这个任务不需要接单,是**正常应答**
203+
而非失败(此前当失败报,误导用户以为出了问题)。
204+
- 其余:真正需要人看的失败,逐条报出。
205+
"""
187206
pending = [task.task_code for task in tasks if task.needs_accept and task.task_code]
188207
titles = {task.task_code: task.title for task in tasks}
208+
accepted = 0
209+
blocked: dict[str, list[str]] = {} # 前置条件 → 受影响的标题
210+
others: list[tuple[str, str]] = [] # (标题, 上游原文)
189211
for start in range(0, len(pending), ACCEPT_BATCH_SIZE):
190212
batch = pending[start:start + ACCEPT_BATCH_SIZE]
191213
try:
@@ -195,13 +217,31 @@ async def _accept_pending(self, credential: CodeBuddyCredential, result: GrowthR
195217
for item in results:
196218
code = item.get("task_code")
197219
title = titles.get(code, code)
198-
if item.get("status") == "error":
199-
message = str(item.get("message") or "未说明原因")
200-
result.steps.append(GrowthStep(
201-
"领取任务", StepStatus.FAILED, f"「{title}」失败:{message}"))
202-
else:
220+
if item.get("status") != "error":
221+
accepted += 1
203222
result.steps.append(GrowthStep(
204223
"领取任务", StepStatus.DONE, f"「{title}」(进度开始计)"))
224+
continue
225+
message = str(item.get("message") or "未说明原因")
226+
if _prerequisite_of(message):
227+
blocked.setdefault(_prerequisite_of(message) or "", []).append(title)
228+
elif "does not require acceptance" in message:
229+
# 正常应答:该任务不需要接单,不用管它(下一步会照常尝试领奖)
230+
continue
231+
else:
232+
others.append((title, message))
233+
if accepted:
234+
result.steps.append(GrowthStep(
235+
"接单完成", StepStatus.DONE, f"共 {accepted} 个任务开始计进度"))
236+
for reason, titles_blocked in blocked.items():
237+
# reportable=True:这是用户需要知道并去处理的事,不能被摘要过滤掉
238+
result.steps.append(GrowthStep(
239+
"接单受阻", StepStatus.IDLE,
240+
f"{len(titles_blocked)} 个任务需先完成「{_prerequisite_label(reason)}」"
241+
f"(在官方客户端操作后自动解除)", reportable=True))
242+
for title, message in others:
243+
result.steps.append(GrowthStep(
244+
"领取任务", StepStatus.FAILED, f"「{title}」失败:{message}"))
205245
return True
206246

207247
async def _claim_completed(self, credential: CodeBuddyCredential, result: GrowthResult,
@@ -422,10 +462,17 @@ def _fmt(value: Any) -> str:
422462
return str(int(number)) if number.is_integer() else f"{number:g}"
423463

424464

465+
def _in_report(step) -> bool:
466+
"""该步是否进一行汇报:显式声明优先,否则 DONE/FAILED 默认进。"""
467+
if step.reportable is not None:
468+
return step.reportable
469+
return step.status in (StepStatus.DONE, StepStatus.FAILED)
470+
471+
425472
def _report(result: GrowthResult) -> str:
426473
"""一行中文汇报(存 events 表 / 直接展示给用户)。"""
427474
parts = [f"{step.name}:{step.detail}" if step.detail else step.name
428-
for step in result.steps if step.status in (StepStatus.DONE, StepStatus.FAILED)]
475+
for step in result.steps if _in_report(step)]
429476
if not parts:
430477
parts = ["成长中心无可领取项"]
431478
tail = []
@@ -439,3 +486,32 @@ def _report(result: GrowthResult) -> str:
439486
if result.credit:
440487
tail.append(f"本次 +共 {_fmt(result.credit)} 积分")
441488
return ";".join(parts) + (f"({','.join(tail)})" if tail else "")
489+
490+
# 前置条件在 tasks/accept 的 results 里以 "prerequisite not met: <task_code>" 出现。
491+
# 已知的前置任务展示名(接口不给标题时回落成 task_code 本身)。
492+
_PREREQUISITE_LABELS = {"first_buddy": "领取一只 Buddy(在客户端新建任务并发起对话)"}
493+
494+
495+
def _is_no_buddy(error: GrowthRejected) -> bool:
496+
"""派出 Buddy 是否因为「还没有 Buddy」被拒——账号状态,不是故障。
497+
498+
上游对这种前置缺失回 400 + `no active buddy`。当故障记会让新账号的报告里
499+
混进一条并不需要处理的"失败",而它与 17 个任务的 first_buddy 是同一根因。
500+
"""
501+
return error.status == 400 and "buddy" in error.message.lower()
502+
503+
504+
def _prerequisite_of(message: str) -> str | None:
505+
"""从失败消息里取出前置任务 code;不是前置条件问题则返回 None。"""
506+
marker = "prerequisite not met:"
507+
lowered = message.lower()
508+
if marker not in lowered:
509+
return None
510+
return message[lowered.index(marker) + len(marker):].strip().split()[0].strip(".,;") \
511+
if message[lowered.index(marker) + len(marker):].strip() else ""
512+
513+
514+
def _prerequisite_label(reason: str) -> str:
515+
"""前置条件的可读标签:接口只给 task_code,这里补一句用户该做什么。"""
516+
return _PREREQUISITE_LABELS.get(reason, reason)
517+

0 commit comments

Comments
 (0)