加入部分消息通知入口,同步前端管理
This commit is contained in:
@@ -44,6 +44,11 @@ _registered_tasks: dict = {} # task_id -> cron_expression
|
||||
SIGNIN_LOG_RETAIN_DAYS = int(os.getenv("SIGNIN_LOG_RETAIN_DAYS", "30"))
|
||||
CLEANUP_BATCH_SIZE = 1000
|
||||
|
||||
# Webhook 推送地址和时间(从 DB 加载,env 作为 fallback)
|
||||
WEBHOOK_URL = os.getenv("WEBHOOK_URL", "")
|
||||
DAILY_REPORT_HOUR = int(os.getenv("DAILY_REPORT_HOUR", "23"))
|
||||
DAILY_REPORT_MINUTE = int(os.getenv("DAILY_REPORT_MINUTE", "30"))
|
||||
|
||||
# Redis 订阅线程是否运行
|
||||
_redis_listener_running = False
|
||||
|
||||
@@ -93,6 +98,56 @@ def _parse_cookies(cookie_str: str) -> dict:
|
||||
|
||||
# =============== 任务同步 ===============
|
||||
|
||||
def load_config_from_db():
|
||||
"""从 DB 加载系统配置,更新全局变量并重新调度日报任务。"""
|
||||
global WEBHOOK_URL, DAILY_REPORT_HOUR, DAILY_REPORT_MINUTE
|
||||
try:
|
||||
config = _run_async(_fetch_config())
|
||||
new_url = config.get("webhook_url", WEBHOOK_URL)
|
||||
new_hour = int(config.get("daily_report_hour", DAILY_REPORT_HOUR))
|
||||
new_minute = int(config.get("daily_report_minute", DAILY_REPORT_MINUTE))
|
||||
|
||||
changed = (new_url != WEBHOOK_URL or new_hour != DAILY_REPORT_HOUR or new_minute != DAILY_REPORT_MINUTE)
|
||||
WEBHOOK_URL = new_url
|
||||
DAILY_REPORT_HOUR = new_hour
|
||||
DAILY_REPORT_MINUTE = new_minute
|
||||
|
||||
if changed and scheduler.running:
|
||||
# 重新调度日报任务
|
||||
try:
|
||||
scheduler.remove_job("daily_report")
|
||||
except Exception:
|
||||
pass
|
||||
if WEBHOOK_URL:
|
||||
scheduler.add_job(
|
||||
send_daily_report,
|
||||
trigger=CronTrigger(hour=DAILY_REPORT_HOUR, minute=DAILY_REPORT_MINUTE, timezone="Asia/Shanghai"),
|
||||
id="daily_report",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
logger.info(f"📊 日报任务已更新: {DAILY_REPORT_HOUR:02d}:{DAILY_REPORT_MINUTE:02d}")
|
||||
else:
|
||||
logger.info("📊 Webhook 为空,日报任务已移除")
|
||||
|
||||
logger.info(f"⚙️ 配置加载完成: webhook={'已配置' if WEBHOOK_URL else '未配置'}, 推送时间={DAILY_REPORT_HOUR:02d}:{DAILY_REPORT_MINUTE:02d}")
|
||||
except Exception as e:
|
||||
logger.warning(f"从 DB 加载配置失败,使用默认值: {e}")
|
||||
|
||||
|
||||
async def _fetch_config() -> dict:
|
||||
from sqlalchemy import select
|
||||
from shared.models.system_config import SystemConfig
|
||||
|
||||
SessionFactory, eng = _make_session()
|
||||
try:
|
||||
async with SessionFactory() as session:
|
||||
result = await session.execute(select(SystemConfig))
|
||||
return {r.key: r.value for r in result.scalars().all()}
|
||||
finally:
|
||||
await eng.dispose()
|
||||
|
||||
|
||||
def sync_db_tasks():
|
||||
"""从 DB 同步任务到 APScheduler。新增的加上,删除的移除,cron 变了的更新。"""
|
||||
try:
|
||||
@@ -339,6 +394,171 @@ async def _do_single_signin(cookies: dict, topic: dict) -> dict:
|
||||
return {"status": "failed", "message": str(e)}
|
||||
|
||||
|
||||
# =============== Webhook 每日报告 ===============
|
||||
|
||||
def send_daily_report():
|
||||
"""每日签到结果 + 账号状态汇总,推送到 Webhook。"""
|
||||
if not WEBHOOK_URL:
|
||||
logger.info("⚠️ WEBHOOK_URL 未配置,跳过每日报告推送")
|
||||
return
|
||||
logger.info("📊 开始生成每日报告...")
|
||||
try:
|
||||
report = _run_async(_build_daily_report())
|
||||
_send_webhook(report)
|
||||
logger.info("✅ 每日报告推送成功")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 每日报告推送失败: {e}")
|
||||
|
||||
|
||||
async def _build_daily_report() -> str:
|
||||
"""从 DB 汇总今日签到数据和账号状态。"""
|
||||
from sqlalchemy import select, func as sa_func
|
||||
from shared.models.account import Account
|
||||
from shared.models.signin_log import SigninLog
|
||||
from shared.models.user import User
|
||||
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
now = datetime.now()
|
||||
|
||||
SessionFactory, eng = _make_session()
|
||||
try:
|
||||
async with SessionFactory() as session:
|
||||
# 1. 账号总览
|
||||
acc_status = await session.execute(
|
||||
select(Account.status, sa_func.count())
|
||||
.group_by(Account.status)
|
||||
)
|
||||
status_map = {row[0]: row[1] for row in acc_status.all()}
|
||||
total_accounts = sum(status_map.values())
|
||||
active = status_map.get("active", 0)
|
||||
pending = status_map.get("pending", 0)
|
||||
invalid = status_map.get("invalid_cookie", 0)
|
||||
|
||||
# 2. 今日签到统计
|
||||
log_stats = await session.execute(
|
||||
select(SigninLog.status, sa_func.count())
|
||||
.where(SigninLog.signed_at >= today_start)
|
||||
.group_by(SigninLog.status)
|
||||
)
|
||||
log_map = {row[0]: row[1] for row in log_stats.all()}
|
||||
success = log_map.get("success", 0)
|
||||
already = log_map.get("failed_already_signed", 0)
|
||||
failed_net = log_map.get("failed_network", 0)
|
||||
total_logs = sum(log_map.values())
|
||||
|
||||
# 3. 今日各账号签到明细
|
||||
detail_rows = await session.execute(
|
||||
select(
|
||||
Account.remark,
|
||||
Account.weibo_user_id,
|
||||
SigninLog.status,
|
||||
sa_func.count(),
|
||||
)
|
||||
.join(Account, SigninLog.account_id == Account.id)
|
||||
.where(SigninLog.signed_at >= today_start)
|
||||
.group_by(Account.id, Account.remark, Account.weibo_user_id, SigninLog.status)
|
||||
.order_by(Account.remark)
|
||||
)
|
||||
# 按账号聚合
|
||||
account_details = {}
|
||||
for remark, uid, st, cnt in detail_rows.all():
|
||||
name = remark or uid
|
||||
if name not in account_details:
|
||||
account_details[name] = {"success": 0, "already": 0, "failed": 0}
|
||||
if st == "success":
|
||||
account_details[name]["success"] += cnt
|
||||
elif st == "failed_already_signed":
|
||||
account_details[name]["already"] += cnt
|
||||
else:
|
||||
account_details[name]["failed"] += cnt
|
||||
|
||||
# 4. Cookie 即将失效的账号(超过 3 天未检查)
|
||||
stale_cutoff = now - timedelta(days=3)
|
||||
stale_result = await session.execute(
|
||||
select(Account.remark, Account.weibo_user_id, Account.last_checked_at)
|
||||
.where(Account.status == "active")
|
||||
.where(
|
||||
(Account.last_checked_at < stale_cutoff) | (Account.last_checked_at == None)
|
||||
)
|
||||
)
|
||||
stale_accounts = [
|
||||
(row[0] or row[1], row[2].strftime("%m-%d %H:%M") if row[2] else "从未")
|
||||
for row in stale_result.all()
|
||||
]
|
||||
|
||||
finally:
|
||||
await eng.dispose()
|
||||
|
||||
# 组装报告
|
||||
lines = [
|
||||
"📊 微博超话签到日报",
|
||||
f"⏰ {now.strftime('%Y-%m-%d %H:%M')}",
|
||||
"━━━━━━━━━━━━━━━━━━",
|
||||
"",
|
||||
"📱 账号状态",
|
||||
f" 总计: {total_accounts} 个",
|
||||
f" ✅ 正常: {active} ⏳ 待验证: {pending} ❌ 失效: {invalid}",
|
||||
]
|
||||
|
||||
if invalid > 0:
|
||||
lines.append(f" ⚠️ 有 {invalid} 个账号 Cookie 已失效,请及时更新")
|
||||
|
||||
lines += [
|
||||
"",
|
||||
"🎯 今日签到",
|
||||
f" 总计: {total_logs} 条记录",
|
||||
f" ✅ 成功: {success} 📌 已签: {already} ❌ 失败: {failed_net}",
|
||||
]
|
||||
|
||||
if account_details:
|
||||
lines += ["", "📋 账号明细"]
|
||||
for name, d in account_details.items():
|
||||
lines.append(f" {name}: ✅{d['success']} 📌{d['already']} ❌{d['failed']}")
|
||||
|
||||
if stale_accounts:
|
||||
lines += ["", "⚠️ 需要关注"]
|
||||
for name, last in stale_accounts:
|
||||
lines.append(f" {name} (上次检查: {last})")
|
||||
|
||||
if total_logs == 0:
|
||||
lines += ["", "💤 今日暂无签到记录"]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _send_webhook(content: str):
|
||||
"""发送消息到 Webhook(自动适配企业微信/钉钉/飞书格式)。"""
|
||||
import httpx
|
||||
|
||||
url = WEBHOOK_URL
|
||||
if not url:
|
||||
return
|
||||
|
||||
# 根据 URL 特征判断平台
|
||||
if "qyapi.weixin.qq.com" in url:
|
||||
# 企业微信
|
||||
payload = {"msgtype": "markdown", "markdown": {"content": content}}
|
||||
elif "oapi.dingtalk.com" in url:
|
||||
# 钉钉
|
||||
payload = {"msgtype": "markdown", "markdown": {"title": "签到日报", "text": content}}
|
||||
elif "open.feishu.cn" in url:
|
||||
# 飞书自定义机器人 - 使用富文本消息
|
||||
# 将 Markdown 转为纯文本(飞书 text 类型不支持 Markdown)
|
||||
plain = content.replace("## ", "").replace("### ", "\n").replace("**", "").replace("> ", "")
|
||||
payload = {"msg_type": "text", "content": {"text": plain}}
|
||||
else:
|
||||
# 通用 JSON(兼容自定义 Webhook)
|
||||
payload = {"text": content, "markdown": content}
|
||||
|
||||
resp = httpx.post(url, json=payload, timeout=15)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Webhook 响应异常: status={resp.status_code}, body={resp.text[:200]}")
|
||||
else:
|
||||
resp_data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
||||
if resp_data.get("code", 0) != 0 and resp_data.get("StatusCode", 0) != 0:
|
||||
logger.warning(f"Webhook 业务异常: {resp.text[:300]}")
|
||||
|
||||
|
||||
# =============== 日志清理 ===============
|
||||
|
||||
def cleanup_old_signin_logs():
|
||||
@@ -402,18 +622,23 @@ def _start_redis_listener():
|
||||
def _listen():
|
||||
global _redis_listener_running
|
||||
_redis_listener_running = True
|
||||
logger.info("📡 Redis 订阅线程启动,监听 task_updates 频道")
|
||||
logger.info("📡 Redis 订阅线程启动,监听 task_updates / config_updates 频道")
|
||||
while _redis_listener_running:
|
||||
try:
|
||||
r = redis.from_url(shared_settings.REDIS_URL, decode_responses=True)
|
||||
pubsub = r.pubsub()
|
||||
pubsub.subscribe("task_updates")
|
||||
pubsub.subscribe("task_updates", "config_updates")
|
||||
for message in pubsub.listen():
|
||||
if not _redis_listener_running:
|
||||
break
|
||||
if message["type"] == "message":
|
||||
logger.info(f"📡 收到任务变更通知: {message['data'][:200]}")
|
||||
sync_db_tasks()
|
||||
channel = message.get("channel", "")
|
||||
if channel == "config_updates":
|
||||
logger.info("📡 收到配置变更通知,重新加载...")
|
||||
load_config_from_db()
|
||||
else:
|
||||
logger.info(f"📡 收到任务变更通知: {message['data'][:200]}")
|
||||
sync_db_tasks()
|
||||
pubsub.close()
|
||||
r.close()
|
||||
except Exception as e:
|
||||
@@ -446,6 +671,9 @@ if __name__ == "__main__":
|
||||
# 启动调度器
|
||||
scheduler.start()
|
||||
|
||||
# 从 DB 加载配置(Webhook 地址、推送时间等)
|
||||
load_config_from_db()
|
||||
|
||||
# 首次同步 DB 任务
|
||||
sync_db_tasks()
|
||||
|
||||
@@ -470,6 +698,20 @@ if __name__ == "__main__":
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
|
||||
# 每天定时推送签到日报到 Webhook(如果 load_config_from_db 已调度则跳过)
|
||||
if WEBHOOK_URL and not scheduler.get_job("daily_report"):
|
||||
scheduler.add_job(
|
||||
send_daily_report,
|
||||
trigger=CronTrigger(hour=DAILY_REPORT_HOUR, minute=DAILY_REPORT_MINUTE, timezone="Asia/Shanghai"),
|
||||
id="daily_report",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
if WEBHOOK_URL:
|
||||
logger.info(f"📊 每日报告: 每天 {DAILY_REPORT_HOUR:02d}:{DAILY_REPORT_MINUTE:02d} 推送到 Webhook")
|
||||
else:
|
||||
logger.info("⚠️ Webhook 未配置,每日报告推送已禁用(可在管理面板设置)")
|
||||
|
||||
logger.info("📋 调度器已启动,等待任务触发...")
|
||||
logger.info(f"📋 日志清理: 每天 03:00, 保留 {SIGNIN_LOG_RETAIN_DAYS} 天")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user