Files
claude-outlonok/TASK_CLAUDE_PAYMENT.md
2026-03-06 00:45:44 +08:00

241 lines
9.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 任务:实现 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 REPLACEchecked_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个tdindex 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. 刷新页面后状态仍然保留(数据库持久化)