由 Sentry 每日分诊自动建立。Sentry issue: AUTO-MAS-BACKEND-3P
https://auto-mas.sentry.io/issues/AUTO-MAS-BACKEND-3P
现象
TypeError: can only join an iterable,11 条事件,release auto-mas@v5.5.0-beta.2(dist 8b071e63e186),用户分布 CN。首次出现 2026-08-31T09:39Z,与 #417 合并时间吻合。
File "app\api\tools.py", line 66, in _track_game_sign_notification
logger.warning(f"后台游戏签到通知部分失败: {'、'.join(failed_channels)}")
TypeError: can only join an iterable
定位
当前 dev 上有两处同源缺陷,Sentry 只抓到了第一处:
app/api/tools.py:66 — _track_game_sign_notification(),后台通知回调路径(已上报)
app/api/tools.py:191 — game_sign() 内对 _dispatch_game_sign_notification() 返回值的处理(未上报,见下)
根因
8b071e63(#417 通知系统统一迁移,2026-08-31)把 app/tools/game_sign_notify.py 的
async def push_game_sign_notification(results: list[dict]) -> list[str]:
改成了
async def push_game_sign_notification(results: list[dict]) -> DispatchResult:
但 app/api/tools.py 的两个消费方仍按 list[str] 使用返回值:
_track_game_sign_notification 的 failed_channels = task.result() 现在拿到的是 DispatchResult;
_dispatch_game_sign_notification 的签名仍写着 -> list[str] | None,实际把 DispatchResult 透传给了 game_sign()。
DispatchResult 是 @dataclass(frozen=True),既不可迭代、也没有 __bool__,因此:
if failed_channels: 恒为真(即使 attempted=0、failed=() 的空结果);
- 随后的
'、'.join(...) 必抛 TypeError。
失败渠道名实际在 DispatchResult.failed(tuple[str, ...]),两处应改为取 .failed,并把 _dispatch_game_sign_notification 的返回标注改成 DispatchResult | None。
复现与影响
开启签到通知(ToolsConfig.GameSign.NotifyEnabled)后执行游戏签到即可触发,两条路径按通知耗时分流:
- 通知在 100ms 内完成 → 走
tools.py:191。异常被 game_sign() 的 except Exception 吞掉,接口返回 500 TypeError: can only join an iterable,用户看到签到失败,但签到其实已成功并落盘。
- 通知超过 100ms 转后台 → 走
tools.py:66。异常落在 asyncio 回调里,即 Sentry 上报的这条;此路径不影响接口返回,只丢日志。
前一条路径未在 Sentry 单独成组(被 game_sign() 的 500 分支吸收),所以实际影响面大于这 11 条事件。
现象
TypeError: can only join an iterable,11 条事件,releaseauto-mas@v5.5.0-beta.2(dist8b071e63e186),用户分布 CN。首次出现 2026-08-31T09:39Z,与 #417 合并时间吻合。定位
当前 dev 上有两处同源缺陷,Sentry 只抓到了第一处:
app/api/tools.py:66—_track_game_sign_notification(),后台通知回调路径(已上报)app/api/tools.py:191—game_sign()内对_dispatch_game_sign_notification()返回值的处理(未上报,见下)根因
8b071e63(#417 通知系统统一迁移,2026-08-31)把app/tools/game_sign_notify.py的改成了
但
app/api/tools.py的两个消费方仍按list[str]使用返回值:_track_game_sign_notification的failed_channels = task.result()现在拿到的是DispatchResult;_dispatch_game_sign_notification的签名仍写着-> list[str] | None,实际把DispatchResult透传给了game_sign()。DispatchResult是@dataclass(frozen=True),既不可迭代、也没有__bool__,因此:if failed_channels:恒为真(即使attempted=0、failed=()的空结果);'、'.join(...)必抛TypeError。失败渠道名实际在
DispatchResult.failed(tuple[str, ...]),两处应改为取.failed,并把_dispatch_game_sign_notification的返回标注改成DispatchResult | None。复现与影响
开启签到通知(
ToolsConfig.GameSign.NotifyEnabled)后执行游戏签到即可触发,两条路径按通知耗时分流:tools.py:191。异常被game_sign()的except Exception吞掉,接口返回 500TypeError: can only join an iterable,用户看到签到失败,但签到其实已成功并落盘。tools.py:66。异常落在 asyncio 回调里,即 Sentry 上报的这条;此路径不影响接口返回,只丢日志。前一条路径未在 Sentry 单独成组(被
game_sign()的 500 分支吸收),所以实际影响面大于这 11 条事件。