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

9.5 KiB
Raw Permalink Blame History

任务:实现 Claude 支付检测工具

需求

自动扫描邮箱收件箱,检测 Anthropic 的支付/退款邮件,自动给账号打标签并记录时间。

匹配规则:

  • 发件人:invoice+statements@mail.anthropic.com
  • 标题含 Your receipt from Anthropic → 标记 已支付Claudestatus=paid),记录支付时间
  • 标题含 Your refund from Anthropic → 标记 已退款status=refunded),记录退款时间
  • 时间取邮件的 receivedDateTime
  • 如果同时存在支付和退款邮件,比较时间取最新的作为当前状态

实现方案(共 5 个改动点)

1. database.py — 新增表 + 4 个方法

init_database() 中创建新表:

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

from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, StreamingResponse

在"命令行入口"注释之前,添加以下内容:

常量:

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 的"导出"按钮之后、"刷新"按钮之前,添加:

<button class="btn btn-primary" id="claudePaymentBtn">
    <i class="bi bi-credit-card"></i>
    <span>Claude检测</span>
</button>

表格表头:在"令牌"和"操作"之间,添加新列:

<th>Claude状态</th>

同时把所有 colspan="6" 改为 colspan="7"(空状态行)。

4. static/script.js — SSE 扫描 + 状态渲染

constructor 中新增:

this.claudePaymentStatuses = {};

bindAccountEvents() 中新增按钮绑定:

document.getElementById('claudePaymentBtn').addEventListener('click', () => this.startClaudePaymentCheck());

loadAccounts() 中:在 renderAccounts() 调用前,先 await this.loadClaudePaymentStatuses()。同时把所有 colspan="6" 改为 colspan="7"

renderAccounts() 中:在令牌列 </td> 和操作列 <td> 之间,插入新列:

<td>${this.renderClaudeStatusBadge(email)}</td>

新增 4 个方法:

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 之前或之后均可)添加:

/* 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/N2/N...
  4. 扫描完成后,有 Anthropic 收据的账号显示绿色"已支付",有退款的显示红色"已退款"
  5. 鼠标悬停徽章可看到支付/退款时间
  6. 刷新页面后状态仍然保留(数据库持久化)