fix(garmin): 两步验证账号同步报 EOFError,改用令牌登录

现象:网页触发同步报 "EOF when reading a line"。

原因:garth 的默认 MFA 提示是 input(),向 stdin 索取验证码。
gunicorn worker 没有 stdin,于是抛出 EOFError——错误信息本身
完全没提到 MFA,看不出该做什么。

方案:把"输验证码"和"日常同步"拆开。
- 新增 garmin_tokens 表存 garth 令牌(Client.dumps/loads 序列化)
- garmin_login.py:在终端里跑一次,可正常输入验证码,
  成功后令牌存库
- _connect() 优先加载令牌并 refresh_oauth2(),命中则完全跳过登录,
  既不需要密码也不需要验证码(令牌有效期约一年)
- 无令牌且密码登录撞上 MFA 时,抛 MFARequired 并给出具体该执行
  哪条命令,而不是把 EOFError 原样抛给用户

接口:
- GET /api/garmin/auth-status 返回是否已有令牌
- /api/garmin/sync 在已有令牌时不再强制要求密码

前端:
- 有令牌时隐藏密码输入框,提示无需密码
- 同步返回 mfaRequired 时,展示需要在 NAS 上执行的具体命令
- 同步请求超时放宽到 180s(一周的天数 + 运动是多次上游调用)
- 成功消息补上运动记录条数

tests (test_garmin_sync.py 新增 12 条,共 35):
- 令牌存取、覆盖不累积、按用户隔离
- 有令牌时绝不调用 login()
- MFA 的 EOFError 转成带操作指引的 MFARequired
- 普通 401 不会被误标成 mfaRequired
- 无令牌且无密码时给出明确拒绝

NAS 真机: 252 passed

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 19:57:40 +08:00
parent 6de7562cd8
commit af0604bce4
8 changed files with 410 additions and 38 deletions

View File

@@ -5,6 +5,8 @@ import './DataSync.css';
function DataSync() {
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [garminPassword, setGarminPassword] = useState('');
const [hasToken, setHasToken] = useState<boolean | null>(null);
const [mfaNeeded, setMfaNeeded] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
@@ -21,6 +23,10 @@ function DataSync() {
useEffect(() => {
loadSyncStatus();
apiClient
.getGarminAuthStatus()
.then(setHasToken)
.catch(() => setHasToken(false));
}, [loadSyncStatus]);
const handleSync = async (e: React.FormEvent) => {
@@ -28,18 +34,21 @@ function DataSync() {
setError('');
setMessage('');
if (!garminPassword) {
if (!hasToken && !garminPassword) {
setError('请输入 Garmin 密码');
return;
}
setLoading(true);
setMfaNeeded(false);
try {
const result = await apiClient.syncGarminData(garminPassword);
const result = await apiClient.syncGarminData(garminPassword || undefined);
if (result.status === 'success') {
setMessage(`同步完成,新增/更新 ${result.recordsSynced} 天数据`);
const acts = result.activitiesSynced ?? 0;
setMessage(`同步完成:${result.recordsSynced} 天数据、${acts} 条运动记录`);
} else {
setError(result.message);
if (result.mfaRequired) setMfaNeeded(true);
}
// Clear the password as soon as the request is done — it is only ever
// held in memory for the duration of the call.
@@ -101,28 +110,51 @@ function DataSync() {
</section>
<form className="sync-actions" onSubmit={handleSync}>
<div className="form-group">
<label htmlFor="garmin-password">Garmin </label>
<input
id="garmin-password"
type="password"
value={garminPassword}
onChange={(e) => setGarminPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
disabled={busy}
/>
{hasToken === false && (
<div className="form-group">
<label htmlFor="garmin-password">Garmin </label>
<input
id="garmin-password"
type="password"
value={garminPassword}
onChange={(e) => setGarminPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
disabled={busy}
/>
<p className="field-hint">
Garmin
</p>
</div>
)}
{hasToken === true && (
<p className="field-hint">
Garmin
Garmin
</p>
</div>
)}
<button type="submit" className="btn btn-primary btn-large" disabled={busy}>
{busy ? '正在同步…' : '立即同步'}
</button>
</form>
{mfaNeeded && (
<div className="info-box">
<h4></h4>
<p style={{ margin: '0 0 0.75rem', color: '#555', lineHeight: 1.8 }}>
NAS
</p>
<pre className="cmd">
{`ssh -p 2222 ericwyuan@192.168.50.64
cd ~/apps/garmin-health-lab/backend
.venv/bin/python garmin_login.py`}
</pre>
</div>
)}
{error && <div className="error-message">{error}</div>}
{message && <div className="success-message">{message}</div>}