Админка: разбивка по дням, время обработки, уведомления о платежах в Telegram

- Статистика по дням (14 дн): заходы/уникальные + файлы/страницы/исходный+конечный размер
- Числа заходов подписаны над столбцами графика
- Карточка «Время обработки»: суммарное время (из elapsed_time) + среднее с/страницу
- Уведомление владельцу в Telegram при успешной оплате (_tg_send через api.telegram.org,
  дедуп через WEB_PAYMENTS.notified, отправка в отдельном потоке)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
berkio_admin_gitea
2026-07-07 13:05:43 +00:00
parent cde1e54d00
commit 3ebf2815a5

View File

@@ -72,6 +72,10 @@ ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "")
# ── Подписка через Telegram-бота (подписанные deep-link) ────────────────────── # ── Подписка через Telegram-бота (подписанные deep-link) ──────────────────────
SUB_LINK_SECRET = os.getenv("SUB_LINK_SECRET", "") # общий секрет бота и веба SUB_LINK_SECRET = os.getenv("SUB_LINK_SECRET", "") # общий секрет бота и веба
# ── Уведомления о платежах в Telegram ─────────────────────────────────────────
BOT_TOKEN = os.getenv("TOKEN", "")
PAYMENT_NOTIFY_CHAT_ID = int(os.getenv("PAYMENT_NOTIFY_CHAT_ID", "156921693"))
# ── Вход по почте (magic-code через SMTP) ───────────────────────────────────── # ── Вход по почте (magic-code через SMTP) ─────────────────────────────────────
SMTP_HOST = os.getenv("SMTP_HOST", "") SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465")) SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
@@ -362,6 +366,13 @@ def _db_ensure_auth_table():
con.execute(f"ALTER TABLE WEB_ACCOUNTS ADD COLUMN {_c} {_ddl}") con.execute(f"ALTER TABLE WEB_ACCOUNTS ADD COLUMN {_c} {_ddl}")
except Exception as _amig: except Exception as _amig:
print(f"WEB_ACCOUNTS migrate error: {_amig}") print(f"WEB_ACCOUNTS migrate error: {_amig}")
# Авто-миграция WEB_PAYMENTS: флаг отправленного уведомления (дедуп)
try:
pcols = {row[1] for row in con.execute("PRAGMA table_info(WEB_PAYMENTS)")}
if "notified" not in pcols:
con.execute("ALTER TABLE WEB_PAYMENTS ADD COLUMN notified INTEGER DEFAULT 0")
except Exception as _pmig:
print(f"WEB_PAYMENTS migrate error: {_pmig}")
con.commit() con.commit()
except Exception as e: except Exception as e:
print(f"WEB_AUTH_TOKENS create error: {e}") print(f"WEB_AUTH_TOKENS create error: {e}")
@@ -1748,6 +1759,45 @@ def _db_set_autorenew_flag(account_id, enable: bool) -> dict:
return {"autorenew": False, "has_method": False} return {"autorenew": False, "has_method": False}
def _tg_send(chat_id, text):
"""Отправка сообщения в Telegram напрямую через Bot API (для уведомлений из веба)."""
if not BOT_TOKEN or not chat_id:
return
try:
data = _urlparse.urlencode({"chat_id": chat_id, "text": text,
"disable_web_page_preview": "true"}).encode()
req = _urlreq.Request(f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
data=data, method="POST")
with _urlreq.urlopen(req, timeout=15) as resp:
resp.read()
except Exception as e:
print(f"tg notify error: {e}")
def _notify_owner_payment(payment_id):
"""Уведомляет владельца об УСПЕШНОМ платеже. Дедуп через WEB_PAYMENTS.notified (шлём один раз)."""
try:
with sqlite3.connect(DB_PATH, timeout=10) as con:
con.row_factory = sqlite3.Row
cur = con.execute("UPDATE WEB_PAYMENTS SET notified=1 WHERE payment_id=? AND COALESCE(notified,0)=0",
(payment_id,))
con.commit()
if cur.rowcount != 1:
return # уже уведомляли
row = con.execute("SELECT amount, purpose, email FROM WEB_PAYMENTS WHERE payment_id=?",
(payment_id,)).fetchone()
except Exception:
return
if not row:
return
label = {"donate": "Донат", "api_pro": "API-тариф", "file": "Оплата файла",
"sub": "Подписка"}.get(row["purpose"], row["purpose"] or "Платёж")
text = f"💰 Оплата получена\n{label}: {int(round(row['amount'] or 0))}"
if row["email"]:
text += f"\n📧 {row['email']}"
_tg_send(PAYMENT_NOTIFY_CHAT_ID, text)
def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None, payment_method=None): def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None, payment_method=None):
"""extra: metadata платежа (job_id для file, account_id для sub). """extra: metadata платежа (job_id для file, account_id для sub).
payment_method: объект payment_method из ЮKassa (для сохранения при автопродлении).""" payment_method: объект payment_method из ЮKassa (для сохранения при автопродлении)."""
@@ -1777,6 +1827,8 @@ def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None, payment_meth
job_id = extra.get("job_id") job_id = extra.get("job_id")
if job_id and _db_get_url_res_f(job_id) == "awaiting_payment": if job_id and _db_get_url_res_f(job_id) == "awaiting_payment":
_db_set_url_res_f(job_id, "-") _db_set_url_res_f(job_id, "-")
# Уведомление владельцу в Telegram (в отдельном потоке — не блокируем event loop)
threading.Thread(target=_notify_owner_payment, args=(payment_id,), daemon=True).start()
except Exception as e: except Exception as e:
print(f"mark paid error: {e}") print(f"mark paid error: {e}")
@@ -2681,6 +2733,8 @@ _ADMIN_HTML = """<!doctype html>
.bars{ display:flex; align-items:flex-end; gap:5px; height:90px; } .bars{ display:flex; align-items:flex-end; gap:5px; height:90px; }
.bars .b{ flex:1; background:var(--blue); border-radius:4px 4px 0 0; min-height:2px; position:relative; opacity:.85; } .bars .b{ flex:1; background:var(--blue); border-radius:4px 4px 0 0; min-height:2px; position:relative; opacity:.85; }
.bars .b span{ position:absolute; bottom:-18px; left:0; right:0; text-align:center; font-size:10px; color:var(--sec); } .bars .b span{ position:absolute; bottom:-18px; left:0; right:0; text-align:center; font-size:10px; color:var(--sec); }
.bars .b b{ position:absolute; top:-15px; left:0; right:0; text-align:center; font-size:10px; font-weight:600; color:var(--ink); }
.bars{ margin-top:16px; }
.muted{ color:var(--sec); } .muted{ color:var(--sec); }
#gate{ max-width:360px; margin:80px auto; background:#fff; border-radius:16px; padding:24px; text-align:center; } #gate{ max-width:360px; margin:80px auto; background:#fff; border-radius:16px; padding:24px; text-align:center; }
#gate input{ width:100%; padding:11px 12px; border:1px solid var(--line); border-radius:10px; font:inherit; margin:14px 0; } #gate input{ width:100%; padding:11px 12px; border:1px solid var(--line); border-radius:10px; font:inherit; margin:14px 0; }
@@ -2711,6 +2765,7 @@ _ADMIN_HTML = """<!doctype html>
<div class="card"><div class="lbl">Доход (успешно)</div><div class="val" id="m-rev">—</div><div class="sub" id="m-rev-sub"></div></div> <div class="card"><div class="lbl">Доход (успешно)</div><div class="val" id="m-rev">—</div><div class="sub" id="m-rev-sub"></div></div>
<div class="card"><div class="lbl">Заходы сегодня</div><div class="val" id="m-vis">—</div><div class="sub" id="m-vis-sub"></div></div> <div class="card"><div class="lbl">Заходы сегодня</div><div class="val" id="m-vis">—</div><div class="sub" id="m-vis-sub"></div></div>
<div class="card"><div class="lbl">Документов обработано</div><div class="val" id="m-docs">—</div><div class="sub" id="m-docs-sub"></div></div> <div class="card"><div class="lbl">Документов обработано</div><div class="val" id="m-docs">—</div><div class="sub" id="m-docs-sub"></div></div>
<div class="card"><div class="lbl">Время обработки</div><div class="val" id="m-time">—</div><div class="sub" id="m-time-sub"></div></div>
</div> </div>
<section> <section>
@@ -2724,6 +2779,12 @@ _ADMIN_HTML = """<!doctype html>
<div class="bars" id="bars"></div> <div class="bars" id="bars"></div>
</section> </section>
<section>
<h2>По дням</h2>
<div class="scroll"><table><thead><tr><th>Дата</th><th class="num">Заходы</th><th class="num">Уник.</th><th class="num">Файлов</th><th class="num">Страниц</th><th class="num">Исходник</th><th class="num">Результат</th></tr></thead>
<tbody id="t-daily"></tbody></table></div>
</section>
<section> <section>
<h2>Платежи <span class="muted" id="pay-breakdown"></span></h2> <h2>Платежи <span class="muted" id="pay-breakdown"></span></h2>
<div class="scroll"><table><thead><tr><th>Дата</th><th class="num">Сумма</th><th>Назначение</th><th>Статус</th><th>Email</th></tr></thead> <div class="scroll"><table><thead><tr><th>Дата</th><th class="num">Сумма</th><th>Назначение</th><th>Статус</th><th>Email</th></tr></thead>
@@ -2746,6 +2807,7 @@ _ADMIN_HTML = """<!doctype html>
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); } function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
function money(v){ return Math.round(v||0).toLocaleString('ru-RU') + ''; } function money(v){ return Math.round(v||0).toLocaleString('ru-RU') + ''; }
function size(mb){ if(mb==null) return ''; mb=+mb; if(!mb) return ''; return mb>=1024 ? (mb/1024).toFixed(2)+' ГБ' : mb.toFixed(1)+' МБ'; } function size(mb){ if(mb==null) return ''; mb=+mb; if(!mb) return ''; return mb>=1024 ? (mb/1024).toFixed(2)+' ГБ' : mb.toFixed(1)+' МБ'; }
function dur(sec){ sec=Math.round(sec||0); if(sec<60) return sec+' с'; var m=Math.floor(sec/60); if(m<60) return m+' мин'; var h=Math.floor(m/60); if(h<24) return h+' ч '+(m%60)+' мин'; return Math.floor(h/24)+' дн '+(h%24)+' ч'; }
function dts(ts){ if(!ts) return ''; var d=new Date(ts*1000); return d.toLocaleString('ru-RU',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}); } function dts(ts){ if(!ts) return ''; var d=new Date(ts*1000); return d.toLocaleString('ru-RU',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}); }
function dOnly(ts){ if(!ts) return ''; var d=new Date(ts*1000); return d.toLocaleDateString('ru-RU'); } function dOnly(ts){ if(!ts) return ''; var d=new Date(ts*1000); return d.toLocaleDateString('ru-RU'); }
function daysLeft(ts){ var s=Math.max(0,(ts*1000-Date.now())/86400000); return s>=365 ? Math.round(s/365)+' г.' : Math.round(s)+' дн.'; } function daysLeft(ts){ var s=Math.max(0,(ts*1000-Date.now())/86400000); return s>=365 ? Math.round(s/365)+' г.' : Math.round(s)+' дн.'; }
@@ -2776,6 +2838,8 @@ _ADMIN_HTML = """<!doctype html>
document.getElementById('m-vis-sub').textContent = 'уник. ' + (d.visits.unique_today||0).toLocaleString('ru-RU') + ' · 7 дн. ' + (d.visits.last7||0).toLocaleString('ru-RU'); document.getElementById('m-vis-sub').textContent = 'уник. ' + (d.visits.unique_today||0).toLocaleString('ru-RU') + ' · 7 дн. ' + (d.visits.last7||0).toLocaleString('ru-RU');
document.getElementById('m-docs').textContent = (d.docs.total_count||0).toLocaleString('ru-RU'); document.getElementById('m-docs').textContent = (d.docs.total_count||0).toLocaleString('ru-RU');
document.getElementById('m-docs-sub').textContent = (d.docs.total_pages||0).toLocaleString('ru-RU') + ' стр. · ' + size(d.docs.total_result_mb); document.getElementById('m-docs-sub').textContent = (d.docs.total_pages||0).toLocaleString('ru-RU') + ' стр. · ' + size(d.docs.total_result_mb);
document.getElementById('m-time').textContent = dur(d.docs.total_seconds);
document.getElementById('m-time-sub').textContent = '~' + (d.docs.avg_page_seconds||0).toFixed(1) + ' с/стр';
// Подписки // Подписки
var subs = d.subs.list || []; var subs = d.subs.list || [];
@@ -2789,11 +2853,20 @@ _ADMIN_HTML = """<!doctype html>
document.getElementById('bars').innerHTML = days.length ? days.map(function(x){ document.getElementById('bars').innerHTML = days.length ? days.map(function(x){
var h = Math.round(x.count/mx*100); var h = Math.round(x.count/mx*100);
var dd = x.day.slice(8)+'.'+x.day.slice(5,7); var dd = x.day.slice(8)+'.'+x.day.slice(5,7);
return '<div class="b" style="height:'+h+'%" title="'+x.day+': '+x.count+' заходов, уник. '+(x.uniques||0)+'"><span>'+dd+'</span></div>'; return '<div class="b" style="height:'+h+'%" title="'+x.day+': '+x.count+' заходов, уник. '+(x.uniques||0)+'"><b>'+x.count+'</b><span>'+dd+'</span></div>';
}).join('') : '<div class="muted">Пока нет данных</div>'; }).join('') : '<div class="muted">Пока нет данных</div>';
document.getElementById('vis-total').textContent = '· всего ' + (d.visits.total||0).toLocaleString('ru-RU') + document.getElementById('vis-total').textContent = '· всего ' + (d.visits.total||0).toLocaleString('ru-RU') +
' (уник. ' + (d.visits.unique_total||0).toLocaleString('ru-RU') + ')'; ' (уник. ' + (d.visits.unique_total||0).toLocaleString('ru-RU') + ')';
// По дням (свежие сверху)
var daily = (d.daily||[]).slice().reverse();
document.getElementById('t-daily').innerHTML = daily.length ? daily.map(function(x){
var dd = x.day.slice(8)+'.'+x.day.slice(5,7)+'.'+x.day.slice(0,4);
return '<tr><td>'+dd+'</td><td class="num">'+(x.visits||0)+'</td><td class="num">'+(x.uniques||0)+
'</td><td class="num">'+(x.files||0)+'</td><td class="num">'+(x.pages||0).toLocaleString('ru-RU')+
'</td><td class="num">'+size(x.src_mb)+'</td><td class="num">'+size(x.res_mb)+'</td></tr>';
}).join('') : '<tr><td colspan="7" class="muted">Пока нет данных</td></tr>';
// Платежи // Платежи
var bp = d.payments.by_purpose || {}; var bp = d.payments.by_purpose || {};
document.getElementById('pay-breakdown').textContent = '· ' + Object.keys(bp).map(function(k){ document.getElementById('pay-breakdown').textContent = '· ' + Object.keys(bp).map(function(k){
@@ -2939,8 +3012,41 @@ def _admin_collect_stats() -> dict:
agg = con.execute( agg = con.execute(
f"SELECT COUNT(*) c, COALESCE(SUM({pages_expr}),0) p, " f"SELECT COUNT(*) c, COALESCE(SUM({pages_expr}),0) p, "
"COALESCE(SUM(source_size_mb),0) ssz, COALESCE(SUM(result_size_mb),0) rsz FROM FILES").fetchone() "COALESCE(SUM(source_size_mb),0) ssz, COALESCE(SUM(result_size_mb),0) rsz FROM FILES").fetchone()
# Общее время обработки + среднее на страницу (elapsed_time = 'H:MM:SS.ffffff')
tot_sec = 0.0
pages_timed = 0
for r in con.execute(
f"SELECT elapsed_time AS et, {pages_expr} AS pg FROM FILES "
"WHERE elapsed_time IS NOT NULL AND TRIM(elapsed_time) NOT IN ('','-')"):
try:
pp = str(r["et"]).split(":")
if len(pp) == 3:
tot_sec += int(pp[0]) * 3600 + int(pp[1]) * 60 + float(pp[2])
pages_timed += (r["pg"] or 0)
except Exception:
pass
out["docs"] = {"recent": docs, "total_count": agg["c"], "total_pages": agg["p"], out["docs"] = {"recent": docs, "total_count": agg["c"], "total_pages": agg["p"],
"total_source_mb": agg["ssz"], "total_result_mb": agg["rsz"]} "total_source_mb": agg["ssz"], "total_result_mb": agg["rsz"],
"total_seconds": tot_sec, "avg_page_seconds": (tot_sec / pages_timed) if pages_timed else 0}
# По дням: заходы/уник + файлы/страницы/размеры (последние 14 дней)
_today = datetime.date.today()
_cutoff = (_today - datetime.timedelta(days=13)).strftime("%Y.%m.%d")
files_by_day = {}
for r in con.execute(
f"SELECT date AS d, COUNT(*) c, COALESCE(SUM({pages_expr}),0) p, "
"COALESCE(SUM(source_size_mb),0) s, COALESCE(SUM(result_size_mb),0) rs "
"FROM FILES WHERE date >= ? GROUP BY date", (_cutoff,)):
files_by_day[(r["d"] or "").replace(".", "-")] = {
"files": r["c"], "pages": r["p"], "src_mb": r["s"], "res_mb": r["rs"]}
daily = []
for i in range(13, -1, -1):
dkey = (_today - datetime.timedelta(days=i)).strftime("%Y-%m-%d")
vc = next((x["count"] for x in days if x["day"] == dkey), 0)
fb = files_by_day.get(dkey, {})
daily.append({"day": dkey, "visits": vc, "uniques": udays.get(dkey, 0),
"files": fb.get("files", 0), "pages": fb.get("pages", 0),
"src_mb": fb.get("src_mb", 0), "res_mb": fb.get("res_mb", 0)})
out["daily"] = daily
return out return out