# 任务:实现 Claude 支付检测工具
## 需求
自动扫描邮箱收件箱,检测 Anthropic 的支付/退款邮件,自动给账号打标签并记录时间。
**匹配规则:**
- 发件人:`invoice+statements@mail.anthropic.com`
- 标题含 `Your receipt from Anthropic` → 标记 **已支付Claude**(status=`paid`),记录支付时间
- 标题含 `Your refund from Anthropic` → 标记 **已退款**(status=`refunded`),记录退款时间
- 时间取邮件的 `receivedDateTime`
- 如果同时存在支付和退款邮件,比较时间取最新的作为当前状态
---
## 实现方案(共 5 个改动点)
### 1. `database.py` — 新增表 + 4 个方法
在 `init_database()` 中创建新表:
```sql
CREATE TABLE IF NOT EXISTS claude_payment_status (
email TEXT PRIMARY KEY,
status TEXT DEFAULT 'unknown', -- paid / refunded / unknown / error
payment_time TEXT,
refund_time TEXT,
checked_at TEXT
)
```
新增 3 个 async 方法(参考现有 `get_account_tags` / `set_account_tags` 的写法):
- `set_claude_payment_status(email, status, payment_time=None, refund_time=None)` — INSERT OR REPLACE,checked_at 取当前时间
- `get_claude_payment_status(email)` → Optional[Dict]
- `get_all_claude_payment_statuses()` → Dict[str, Dict]
修改 `delete_account()`:在删除账户时联动删除 `claude_payment_status` 表中对应记录。
### 2. `mail_api.py` — 检测逻辑 + 2 个新端点
添加 `StreamingResponse` 到 import:
```python
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, StreamingResponse
```
在"命令行入口"注释之前,添加以下内容:
**常量:**
```python
ANTHROPIC_SENDER = "invoice+statements@mail.anthropic.com"
RECEIPT_KEYWORD = "Your receipt from Anthropic"
REFUND_KEYWORD = "Your refund from Anthropic"
```
**核心检测函数** `_check_claude_payment_for_account(email_addr)`:
1. 调用 `email_manager.get_client(email_addr)` 获取客户端
2. 调用 `client.get_messages_with_content(top=30)` 获取最近 30 封 INBOX 邮件
3. 遍历邮件,匹配 `sender.emailAddress.address` == ANTHROPIC_SENDER(不区分大小写)
4. 匹配标题关键词,记录最新的 payment_time 和 refund_time
5. 确定状态:两者都有则比较时间取最新;只有支付=paid;只有退款=refunded;都没有=unknown
6. 调用 `db_manager.set_claude_payment_status()` 写入数据库
7. 同时双写标签:获取现有标签,移除旧的"已支付Claude"/"已退款"标签,根据状态添加新标签
8. 返回 `{email, status, payment_time, refund_time}`
9. 出错时记录 error 状态并返回 `{email, status: "error", message: str(e)}`
**端点 1:** `POST /api/tools/check-claude-payment` — SSE 流式扫描
- 获取所有账户 `load_accounts_config()`
- 逐个调用 `_check_claude_payment_for_account()`
- SSE 事件格式(每行 `data: {JSON}\n\n`):
- `{type: "start", total: N}`
- `{type: "progress", current: i, total: N, email: "..."}`(扫描前发送)
- `{type: "result", current: i, total: N, email, status, payment_time, refund_time}`(扫描后发送)
- `{type: "done", total: N}`
- 账户之间 `await asyncio.sleep(0.5)` 避免限速
- 返回 `StreamingResponse(event_generator(), media_type="text/event-stream")`
**端点 2:** `GET /api/tools/claude-payment-status` — 获取缓存状态
- 调用 `db_manager.get_all_claude_payment_statuses()`
- 返回 `ApiResponse(success=True, data=statuses)`
### 3. `static/index.html` — 按钮 + 表格列
**导航栏**:在 `nav-actions` 的"导出"按钮之后、"刷新"按钮之前,添加:
```html
```
**表格表头**:在"令牌"和"操作"之间,添加新列:
```html
Claude状态 |
```
同时把所有 `colspan="6"` 改为 `colspan="7"`(空状态行)。
### 4. `static/script.js` — SSE 扫描 + 状态渲染
**constructor** 中新增:
```javascript
this.claudePaymentStatuses = {};
```
**bindAccountEvents()** 中新增按钮绑定:
```javascript
document.getElementById('claudePaymentBtn').addEventListener('click', () => this.startClaudePaymentCheck());
```
**loadAccounts()** 中:在 `renderAccounts()` 调用前,先 `await this.loadClaudePaymentStatuses()`。同时把所有 `colspan="6"` 改为 `colspan="7"`。
**renderAccounts()** 中:在令牌列 `` 和操作列 `` 之间,插入新列:
```javascript
| ${this.renderClaudeStatusBadge(email)} |
```
**新增 4 个方法:**
```javascript
async loadClaudePaymentStatuses() {
try {
const resp = await fetch('/api/tools/claude-payment-status');
if (resp.ok) {
const result = await resp.json();
if (result.success) this.claudePaymentStatuses = result.data || {};
}
} catch (e) { console.error('加载Claude支付状态失败:', e); }
}
renderClaudeStatusBadge(email) {
const info = this.claudePaymentStatuses[email];
if (!info) return '未检测';
const map = {
paid: { cls: 'claude-paid', label: '已支付' },
refunded: { cls: 'claude-refunded', label: '已退款' },
unknown: { cls: 'claude-unknown', label: '未知' },
error: { cls: 'claude-error', label: '错误' }
};
const s = map[info.status] || map.unknown;
let tips = [];
if (info.payment_time) tips.push('支付: ' + info.payment_time);
if (info.refund_time) tips.push('退款: ' + info.refund_time);
if (info.checked_at) tips.push('检测: ' + info.checked_at);
const title = tips.length ? ` title="${this.escapeHtml(tips.join('\n'))}"` : '';
return `${s.label}`;
}
async startClaudePaymentCheck() {
const btn = document.getElementById('claudePaymentBtn');
if (!btn) return;
btn.disabled = true;
const origHtml = btn.innerHTML;
btn.innerHTML = '检测中...';
try {
const response = await fetch('/api/tools/check-claude-payment', { method: 'POST' });
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'progress') {
btn.innerHTML = `${data.current}/${data.total}`;
} else if (data.type === 'result') {
this.claudePaymentStatuses[data.email] = {
status: data.status,
payment_time: data.payment_time || null,
refund_time: data.refund_time || null,
checked_at: new Date().toLocaleString('zh-CN')
};
this.updateClaudeBadgeInTable(data.email);
} else if (data.type === 'done') {
btn.innerHTML = '完成';
setTimeout(() => { btn.innerHTML = origHtml; btn.disabled = false; }, 2000);
}
} catch (e) {}
}
}
} catch (err) {
console.error('Claude支付检测失败:', err);
btn.innerHTML = '失败';
setTimeout(() => { btn.innerHTML = origHtml; btn.disabled = false; }, 2000);
}
}
updateClaudeBadgeInTable(email) {
const tbody = document.getElementById('accountTableBody');
if (!tbody) return;
for (const row of tbody.querySelectorAll('tr')) {
const firstTd = row.querySelector('td');
if (firstTd && firstTd.textContent.includes(email)) {
// Claude状态列 = 第6个td(index 5,在令牌后、操作前)
const cells = row.querySelectorAll('td');
if (cells.length >= 6) {
cells[5].innerHTML = this.renderClaudeStatusBadge(email);
}
break;
}
}
}
```
### 5. `static/style.css` — 徽章样式
在文件末尾(`@media` 之前或之后均可)添加:
```css
/* Claude支付状态徽章 */
.claude-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
cursor: default;
white-space: nowrap;
}
.claude-paid { background: rgba(16, 185, 129, 0.12); color: #059669; }
.claude-refunded { background: rgba(245, 87, 108, 0.12); color: #e11d48; }
.claude-unknown { background: rgba(148, 163, 184, 0.15); color: #64748b; }
.claude-error { background: rgba(245, 158, 11, 0.15); color: #d97706; }
```
---
## 验证步骤
1. `python mail_api.py web` → 访问 `http://localhost:5001/`
2. 表格应多出一列"Claude状态",初始都显示灰色"未检测"
3. 点击导航栏"Claude检测"按钮 → 按钮显示进度 `1/N`、`2/N`...
4. 扫描完成后,有 Anthropic 收据的账号显示绿色"已支付",有退款的显示红色"已退款"
5. 鼠标悬停徽章可看到支付/退款时间
6. 刷新页面后状态仍然保留(数据库持久化)