241 lines
9.5 KiB
Markdown
241 lines
9.5 KiB
Markdown
# 任务:实现 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
|
||
<button class="btn btn-primary" id="claudePaymentBtn">
|
||
<i class="bi bi-credit-card"></i>
|
||
<span>Claude检测</span>
|
||
</button>
|
||
```
|
||
|
||
**表格表头**:在"令牌"和"操作"之间,添加新列:
|
||
```html
|
||
<th>Claude状态</th>
|
||
```
|
||
同时把所有 `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()** 中:在令牌列 `</td>` 和操作列 `<td>` 之间,插入新列:
|
||
```javascript
|
||
<td>${this.renderClaudeStatusBadge(email)}</td>
|
||
```
|
||
|
||
**新增 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 '<span class="claude-badge claude-unknown">未检测</span>';
|
||
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 `<span class="claude-badge ${s.cls}"${title}>${s.label}</span>`;
|
||
}
|
||
|
||
async startClaudePaymentCheck() {
|
||
const btn = document.getElementById('claudePaymentBtn');
|
||
if (!btn) return;
|
||
btn.disabled = true;
|
||
const origHtml = btn.innerHTML;
|
||
btn.innerHTML = '<i class="bi bi-hourglass-split"></i><span>检测中...</span>';
|
||
|
||
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 = `<i class="bi bi-hourglass-split"></i><span>${data.current}/${data.total}</span>`;
|
||
} 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 = '<i class="bi bi-check-circle"></i><span>完成</span>';
|
||
setTimeout(() => { btn.innerHTML = origHtml; btn.disabled = false; }, 2000);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Claude支付检测失败:', err);
|
||
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>失败</span>';
|
||
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. 刷新页面后状态仍然保留(数据库持久化)
|