Add notification system for Claude payment status changes

Detect refund/suspension status changes and generate notifications
stored in Redis. Bell icon in navbar shows unread count badge,
click to expand dropdown with dismiss per-item or read-all.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 17:00:05 +08:00
parent 18ae09af12
commit a5fd90cb1e
5 changed files with 400 additions and 0 deletions

View File

@@ -7,6 +7,8 @@ Redis缓存模块
import json import json
import logging import logging
import os import os
import uuid
from datetime import datetime
from typing import Any, Optional from typing import Any, Optional
import redis.asyncio as redis import redis.asyncio as redis
@@ -19,6 +21,9 @@ REDIS_URL = 'redis://:redis_XMiXNa@127.0.0.1:6379/0'
TTL_ACCOUNTS = 300 # 账户列表 5分钟 TTL_ACCOUNTS = 300 # 账户列表 5分钟
TTL_MESSAGES = 180 # 邮件列表 3分钟 TTL_MESSAGES = 180 # 邮件列表 3分钟
TTL_PAYMENT = 600 # 支付状态 10分钟 TTL_PAYMENT = 600 # 支付状态 10分钟
TTL_NOTIFICATIONS = 604800 # 通知 7天
NOTIFICATIONS_KEY = 'notifications:unread'
class RedisCache: class RedisCache:
@@ -101,4 +106,58 @@ class RedisCache:
await self.delete(self.payment_key()) await self.delete(self.payment_key())
# ---- 通知系统 ----
async def add_notification(self, email: str, ntype: str, message: str):
"""添加一条通知LPUSH + EXPIRE"""
if not self._redis:
return
try:
notif = json.dumps({
'id': str(uuid.uuid4()),
'email': email,
'type': ntype,
'message': message,
'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}, ensure_ascii=False)
await self._redis.lpush(NOTIFICATIONS_KEY, notif)
await self._redis.expire(NOTIFICATIONS_KEY, TTL_NOTIFICATIONS)
except Exception as e:
logger.debug(f"添加通知失败: {e}")
async def get_notifications(self) -> list:
"""获取所有未读通知"""
if not self._redis:
return []
try:
items = await self._redis.lrange(NOTIFICATIONS_KEY, 0, -1)
return [json.loads(item) for item in items]
except Exception as e:
logger.debug(f"获取通知失败: {e}")
return []
async def dismiss_notification(self, notif_id: str):
"""移除指定 id 的通知"""
if not self._redis:
return
try:
items = await self._redis.lrange(NOTIFICATIONS_KEY, 0, -1)
for item in items:
data = json.loads(item)
if data.get('id') == notif_id:
await self._redis.lrem(NOTIFICATIONS_KEY, 1, item)
break
except Exception as e:
logger.debug(f"移除通知失败: {e}")
async def dismiss_all_notifications(self):
"""清除所有通知"""
if not self._redis:
return
try:
await self._redis.delete(NOTIFICATIONS_KEY)
except Exception:
pass
cache = RedisCache() cache = RedisCache()

View File

@@ -1166,9 +1166,20 @@ async def _check_claude_payment_for_account(email_addr: str) -> dict:
else: else:
status = 'unknown' status = 'unknown'
# 查旧状态,状态变化时生成通知
old_info = await db_manager.get_claude_payment_status(email_addr)
old_status = old_info.get('status') if old_info else None
# 写入数据库 # 写入数据库
await db_manager.set_claude_payment_status(email_addr, status, payment_time, refund_time, suspended_time) await db_manager.set_claude_payment_status(email_addr, status, payment_time, refund_time, suspended_time)
# 仅状态变化时创建通知(避免重复检测重复通知)
if status != old_status:
if status == 'refunded':
await cache.add_notification(email_addr, 'refund', f'{email_addr} 已退款')
elif status == 'suspended':
await cache.add_notification(email_addr, 'suspension', f'{email_addr} 已封号')
# 如果是支付状态且标题和备注为空,提取收据信息 # 如果是支付状态且标题和备注为空,提取收据信息
if status == 'paid' and payment_msg: if status == 'paid' and payment_msg:
current_info = await db_manager.get_claude_payment_status(email_addr) current_info = await db_manager.get_claude_payment_status(email_addr)
@@ -1308,6 +1319,26 @@ async def get_claude_payment_status() -> ApiResponse:
await cache.set(cache.payment_key(), statuses, TTL_PAYMENT) await cache.set(cache.payment_key(), statuses, TTL_PAYMENT)
return ApiResponse(success=True, data=statuses) return ApiResponse(success=True, data=statuses)
# ============================================================================
# 通知系统
# ============================================================================
@app.get("/api/notifications")
async def get_notifications():
"""获取未读通知列表"""
notifications = await cache.get_notifications()
return {"success": True, "data": notifications}
@app.post("/api/notifications/dismiss")
async def dismiss_notification(request: Request):
"""标记通知已读:传 {id} 移除单条,传 {all:true} 清除全部"""
body = await request.json()
if body.get('all'):
await cache.dismiss_all_notifications()
elif body.get('id'):
await cache.dismiss_notification(body['id'])
return {"success": True}
# ============================================================================ # ============================================================================
# 命令行入口 # 命令行入口
# ============================================================================ # ============================================================================

View File

@@ -65,6 +65,19 @@
<option value="suspended">已封号</option> <option value="suspended">已封号</option>
<option value="unchecked">未检测</option> <option value="unchecked">未检测</option>
</select> </select>
<div class="notification-wrapper">
<button class="btn btn-icon" id="notificationBtn" title="通知">
<i class="bi bi-bell"></i>
<span class="notification-badge" id="notifBadge" style="display:none">0</span>
</button>
<div class="notification-dropdown" id="notifDropdown" style="display:none">
<div class="notif-header">
<span>通知</span>
<button class="notif-read-all-btn" id="notifReadAll">全部已读</button>
</div>
<div class="notif-list" id="notifList"></div>
</div>
</div>
<button class="btn btn-icon" id="refreshAccountsBtn" title="刷新"> <button class="btn btn-icon" id="refreshAccountsBtn" title="刷新">
<i class="bi bi-arrow-clockwise"></i> <i class="bi bi-arrow-clockwise"></i>
</button> </button>

View File

@@ -58,7 +58,9 @@ class MailManager {
init() { init() {
this.bindAccountEvents(); this.bindAccountEvents();
this.bindEmailEvents(); this.bindEmailEvents();
this.bindNotificationEvents();
this.loadAccounts(); this.loadAccounts();
this.loadNotifications();
} }
// ==================================================================== // ====================================================================
@@ -1215,6 +1217,7 @@ class MailManager {
} else if (data.type === 'done') { } else if (data.type === 'done') {
btn.innerHTML = '<i class="bi bi-check-circle"></i><span>完成</span>'; btn.innerHTML = '<i class="bi bi-check-circle"></i><span>完成</span>';
setTimeout(() => { btn.innerHTML = origHtml; btn.disabled = false; }, 2000); setTimeout(() => { btn.innerHTML = origHtml; btn.disabled = false; }, 2000);
this.loadNotifications();
} }
} catch (e) {} } catch (e) {}
} }
@@ -1245,6 +1248,7 @@ class MailManager {
}; };
this.updateClaudeBadgeInTable(email); this.updateClaudeBadgeInTable(email);
this.showToast(`${email} 检测完成: ${result.data.status}`, 'success'); this.showToast(`${email} 检测完成: ${result.data.status}`, 'success');
this.loadNotifications();
} else { } else {
this.showToast(result.message || '检测失败', 'danger'); this.showToast(result.message || '检测失败', 'danger');
} }
@@ -1278,6 +1282,106 @@ class MailManager {
} }
} }
// ====================================================================
// 通知系统
// ====================================================================
bindNotificationEvents() {
const btn = document.getElementById('notificationBtn');
const dropdown = document.getElementById('notifDropdown');
// 铃铛点击切换下拉面板
btn.addEventListener('click', (e) => {
e.stopPropagation();
const visible = dropdown.style.display !== 'none';
dropdown.style.display = visible ? 'none' : 'flex';
});
// 点击页面其他位置关闭
document.addEventListener('click', (e) => {
if (!e.target.closest('.notification-wrapper')) {
dropdown.style.display = 'none';
}
});
// 全部已读
document.getElementById('notifReadAll').addEventListener('click', () => this.dismissAllNotifications());
}
async loadNotifications() {
try {
const resp = await fetch('/api/notifications');
const result = await resp.json();
if (result.success) {
this.renderNotifications(result.data || []);
}
} catch (e) {
console.error('加载通知失败:', e);
}
}
renderNotifications(list) {
const badge = document.getElementById('notifBadge');
const listEl = document.getElementById('notifList');
// 更新角标
if (list.length > 0) {
badge.textContent = list.length;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
// 渲染列表
if (list.length === 0) {
listEl.innerHTML = '<div class="notif-empty"><i class="bi bi-bell-slash"></i>暂无通知</div>';
return;
}
listEl.innerHTML = list.map(n => {
const icon = n.type === 'suspension' ? 'bi-exclamation-triangle-fill' : 'bi-arrow-return-left';
return `
<div class="notif-item ${n.type}">
<div class="notif-icon"><i class="bi ${icon}"></i></div>
<div class="notif-content">
<div class="notif-message">${this.escapeHtml(n.message)}</div>
<div class="notif-time">${n.created_at}</div>
</div>
<button class="notif-close" onclick="app.dismissNotification('${n.id}')" title="已读">
<i class="bi bi-x"></i>
</button>
</div>
`;
}).join('');
}
async dismissNotification(id) {
try {
await fetch('/api/notifications/dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
this.loadNotifications();
} catch (e) {
console.error('移除通知失败:', e);
}
}
async dismissAllNotifications() {
try {
await fetch('/api/notifications/dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all: true })
});
this.loadNotifications();
document.getElementById('notifDropdown').style.display = 'none';
} catch (e) {
console.error('清除通知失败:', e);
}
}
// ==================================================================== // ====================================================================
// 工具方法 // 工具方法
// ==================================================================== // ====================================================================

View File

@@ -1576,7 +1576,200 @@ body {
.pagination-controls { width: 100%; justify-content: center; } .pagination-controls { width: 100%; justify-content: center; }
} }
/* ============================================================================
通知系统
============================================================================ */
.notification-wrapper {
position: relative;
}
.notification-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: linear-gradient(135deg, #f5576c 0%, #e11d48 100%);
color: white;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
box-shadow: 0 2px 6px rgba(245, 87, 108, 0.4);
animation: badgePop 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes badgePop {
0% { transform: scale(0); }
60% { transform: scale(1.2); }
100% { transform: scale(1); }
}
.notification-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 360px;
max-height: 420px;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(20px);
border-radius: 12px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04);
z-index: 2000;
display: flex;
flex-direction: column;
animation: notifSlideIn 0.2s ease;
overflow: hidden;
}
@keyframes notifSlideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.notif-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
font-size: 14px;
font-weight: 700;
color: #1e293b;
}
.notif-read-all-btn {
border: none;
background: rgba(102, 126, 234, 0.1);
color: #667eea;
font-size: 12px;
font-weight: 600;
padding: 4px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.notif-read-all-btn:hover {
background: #667eea;
color: white;
}
.notif-list {
overflow-y: auto;
max-height: 360px;
flex: 1;
}
.notif-list::-webkit-scrollbar { width: 4px; }
.notif-list::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
.notif-empty {
text-align: center;
padding: 32px 16px;
color: #94a3b8;
font-size: 13px;
}
.notif-empty i {
font-size: 32px;
display: block;
margin-bottom: 8px;
opacity: 0.4;
}
.notif-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
border-left: 3px solid transparent;
transition: background 0.15s ease;
animation: fadeIn 0.2s ease;
}
.notif-item:hover {
background: rgba(0, 0, 0, 0.02);
}
.notif-item.suspension {
border-left-color: #dc2626;
}
.notif-item.refund {
border-left-color: #f59e0b;
}
.notif-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
margin-top: 2px;
}
.notif-item.suspension .notif-icon {
background: rgba(220, 38, 38, 0.1);
color: #dc2626;
}
.notif-item.refund .notif-icon {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.notif-content {
flex: 1;
min-width: 0;
}
.notif-message {
font-size: 13px;
font-weight: 500;
color: #334155;
line-height: 1.4;
word-break: break-all;
}
.notif-time {
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
}
.notif-close {
width: 24px;
height: 24px;
border: none;
background: transparent;
color: #94a3b8;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
transition: all 0.2s ease;
margin-top: 2px;
}
.notif-close:hover {
background: rgba(245, 87, 108, 0.1);
color: #f5576c;
}
@media (max-width: 480px) { @media (max-width: 480px) {
.notification-dropdown { width: 300px; right: -40px; }
.email-list-panel { width: 280px; left: -280px; } .email-list-panel { width: 280px; left: -280px; }
.search-box { width: 100px; } .search-box { width: 100px; }