增加/status状态页,tag参数请求增加or,总请求数持久化,增加瀑布流页面/wtf
This commit is contained in:
1 parent
638a2852f9
commit
6a843ceafd
11 files changed
+1271
-41
No files matched your search
@@ -49,6 +49,7 @@ def _build_docs_html(*, base_url: str) -> str:
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>Random Mage Docs</title>
|
||||
<style>
|
||||
:root {{
|
||||
@@ -70,6 +71,7 @@ def _build_docs_html(*, base_url: str) -> str:
|
||||
}}
|
||||
|
||||
* {{ box-sizing: border-box; }}
|
||||
html, body {{ height: 100%; }}
|
||||
body {{
|
||||
margin: 0;
|
||||
font-family: var(--sans);
|
||||
@@ -336,8 +338,8 @@ def _build_docs_html(*, base_url: str) -> str:
|
||||
<tr>
|
||||
<td><code>included_tags</code></td>
|
||||
<td>
|
||||
必须包含的标签(AND)。可重复传参:<code>included_tags=a&included_tags=b</code>。<br/>
|
||||
也支持用 <code>|</code> 分隔:<code>included_tags=a|b</code> 等价于同时包含 a 与 b。<br/>
|
||||
必须包含的标签:参数之间是 <strong>AND</strong>,单个参数内用 <code>|</code> 表示 <strong>OR</strong>。<br/>
|
||||
例:<code>included_tags=girl|boy&included_tags=white|black</code> 表示 (girl OR boy) AND (white OR black)。<br/>
|
||||
示例:<a href="{examples["tag_loli"]}">{examples["tag_loli"]}</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -30,6 +30,8 @@ from app.jobs.enqueue import enqueue_opportunistic_hydrate_metadata
|
||||
router = APIRouter()
|
||||
|
||||
_MAX_TAG_FILTERS = 50
|
||||
_MAX_TAG_OR_TERMS = 20
|
||||
_MAX_TAG_TOTAL_TERMS = 200
|
||||
|
||||
|
||||
def _as_nonneg_int(value: Any) -> int:
|
||||
@@ -241,22 +243,49 @@ async def random_image(
|
||||
if not min_explicit and min_width_i == 0 and min_height_i == 0 and min_pixels_i == 0:
|
||||
min_pixels_i = 1_000_000 if is_mobile else 2_000_000
|
||||
|
||||
def _parse_tags(values: list[str] | None) -> list[str]:
|
||||
def _parse_tag_filters(values: list[str] | None) -> list[str]:
|
||||
"""
|
||||
Tag filters support "AND of groups" where each query param is one group.
|
||||
|
||||
Examples:
|
||||
- included_tags=girl&included_tags=boy -> girl AND boy
|
||||
- included_tags=girl|boy -> girl OR boy
|
||||
- included_tags=girl|boy&included_tags=white|black -> (girl OR boy) AND (white OR black)
|
||||
"""
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in values or []:
|
||||
for part in str(raw).split("|"):
|
||||
name = part.strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
out.append(name)
|
||||
expr = str(raw or "").strip()
|
||||
if not expr or expr in seen:
|
||||
continue
|
||||
seen.add(expr)
|
||||
out.append(expr)
|
||||
return out
|
||||
|
||||
included = _parse_tags(included_tags)
|
||||
excluded = _parse_tags(excluded_tags)
|
||||
included = _parse_tag_filters(included_tags)
|
||||
excluded = _parse_tag_filters(excluded_tags)
|
||||
|
||||
def _validate_tag_filters(values: list[str]) -> None:
|
||||
total_terms = 0
|
||||
for expr in values:
|
||||
parts: list[str] = []
|
||||
seen_terms: set[str] = set()
|
||||
for part in str(expr).split("|"):
|
||||
term = part.strip()
|
||||
if not term or term in seen_terms:
|
||||
continue
|
||||
seen_terms.add(term)
|
||||
parts.append(term)
|
||||
if len(parts) > _MAX_TAG_OR_TERMS:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Too many tag terms in a group", status_code=400)
|
||||
total_terms += len(parts)
|
||||
if total_terms > _MAX_TAG_TOTAL_TERMS:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Too many tag terms", status_code=400)
|
||||
|
||||
if len(included) > _MAX_TAG_FILTERS or len(excluded) > _MAX_TAG_FILTERS:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Too many tag filters", status_code=400)
|
||||
_validate_tag_filters(included)
|
||||
_validate_tag_filters(excluded)
|
||||
if user_id is not None and int(user_id) <= 0:
|
||||
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported user_id", status_code=400)
|
||||
if illust_id is not None and int(illust_id) <= 0:
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.core.request_id import get_or_create_request_id, set_request_id_header, set_request_id_on_state
|
||||
from app.core.time import iso_utc_ms
|
||||
from app.db.session import with_sqlite_busy_retry
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _clamp_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
async def _query_gallery_stats(engine) -> dict[str, Any]:
|
||||
async def _op() -> dict[str, Any]:
|
||||
async with engine.connect() as conn:
|
||||
images_total = int((await conn.exec_driver_sql("SELECT COUNT(*) FROM images;")).scalar_one())
|
||||
images_enabled = int((await conn.exec_driver_sql("SELECT COUNT(*) FROM images WHERE status=1;")).scalar_one())
|
||||
illust_total = int((await conn.exec_driver_sql("SELECT COUNT(DISTINCT illust_id) FROM images;")).scalar_one())
|
||||
authors_total = int(
|
||||
(
|
||||
await conn.exec_driver_sql(
|
||||
"SELECT COUNT(DISTINCT user_id) FROM images WHERE user_id IS NOT NULL;"
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
r18_count = int((await conn.exec_driver_sql("SELECT COUNT(*) FROM images WHERE status=1 AND x_restrict=1;")).scalar_one())
|
||||
safe_count = int((await conn.exec_driver_sql("SELECT COUNT(*) FROM images WHERE status=1 AND x_restrict=0;")).scalar_one())
|
||||
r18_unknown = int(
|
||||
(await conn.exec_driver_sql("SELECT COUNT(*) FROM images WHERE status=1 AND x_restrict IS NULL;")).scalar_one()
|
||||
)
|
||||
|
||||
ai_count = int((await conn.exec_driver_sql("SELECT COUNT(*) FROM images WHERE status=1 AND ai_type=1;")).scalar_one())
|
||||
non_ai_count = int((await conn.exec_driver_sql("SELECT COUNT(*) FROM images WHERE status=1 AND ai_type=0;")).scalar_one())
|
||||
ai_unknown = int(
|
||||
(await conn.exec_driver_sql("SELECT COUNT(*) FROM images WHERE status=1 AND ai_type IS NULL;")).scalar_one()
|
||||
)
|
||||
|
||||
tag_id = (
|
||||
await conn.exec_driver_sql(
|
||||
"SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1;",
|
||||
("loli",),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if tag_id is None:
|
||||
loli_count = 0
|
||||
loli_found = False
|
||||
else:
|
||||
loli_found = True
|
||||
loli_count = int(
|
||||
(
|
||||
await conn.exec_driver_sql(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT it.image_id)
|
||||
FROM image_tags it
|
||||
JOIN images i ON i.id = it.image_id
|
||||
WHERE it.tag_id = ? AND i.status = 1;
|
||||
""".strip(),
|
||||
(int(tag_id),),
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
non_loli_count = max(0, int(images_enabled) - int(loli_count))
|
||||
|
||||
return {
|
||||
"gallery": {
|
||||
"images_total": images_total,
|
||||
"images_enabled": images_enabled,
|
||||
"illust_total": illust_total,
|
||||
"authors_total": authors_total,
|
||||
},
|
||||
"breakdown": {
|
||||
"r18": {"r18": r18_count, "safe": safe_count, "unknown": r18_unknown},
|
||||
"ai": {"ai": ai_count, "non_ai": non_ai_count, "unknown": ai_unknown},
|
||||
"loli": {"loli": loli_count, "non_loli": non_loli_count, "tag_found": bool(loli_found)},
|
||||
},
|
||||
}
|
||||
|
||||
return await with_sqlite_busy_retry(_op)
|
||||
|
||||
|
||||
def _build_status_html(*, base_url: str, status_code: int, payload: dict[str, Any]) -> str:
|
||||
base = (base_url or "").rstrip("/")
|
||||
if not base:
|
||||
base = ""
|
||||
|
||||
def u(path: str) -> str:
|
||||
path_norm = (path or "").strip()
|
||||
if not path_norm.startswith("/"):
|
||||
path_norm = "/" + path_norm
|
||||
return f"{base}{path_norm}"
|
||||
|
||||
api_status = str(payload.get("api_status") or "unknown")
|
||||
api_status_code = int(payload.get("api_status_code") or status_code)
|
||||
updated_at = str(payload.get("updated_at") or "")
|
||||
|
||||
gallery = payload.get("gallery") if isinstance(payload.get("gallery"), dict) else {}
|
||||
images_total = _clamp_int(gallery.get("images_total"))
|
||||
illust_total = _clamp_int(gallery.get("illust_total"))
|
||||
authors_total = _clamp_int(gallery.get("authors_total"))
|
||||
|
||||
random_stats = payload.get("random") if isinstance(payload.get("random"), dict) else {}
|
||||
random_total = _clamp_int(random_stats.get("total_requests"))
|
||||
random_in_flight = _clamp_int(random_stats.get("in_flight"))
|
||||
last_window_requests = _clamp_int(random_stats.get("last_window_requests"))
|
||||
last_window_success_rate = float(random_stats.get("last_window_success_rate") or 0.0)
|
||||
if not math.isfinite(last_window_success_rate):
|
||||
last_window_success_rate = 0.0
|
||||
last_window_success_rate = max(0.0, min(float(last_window_success_rate), 1.0))
|
||||
|
||||
json_url = u("/status.json")
|
||||
docs_url = u("/docs")
|
||||
random_url = u("/random")
|
||||
admin_url = u("/admin")
|
||||
|
||||
return f"""<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>Status · Random Mage</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #f4efe6;
|
||||
--card: rgba(255, 250, 243, 0.80);
|
||||
--card-2: rgba(255, 250, 243, 0.92);
|
||||
--border: rgba(58, 38, 26, 0.14);
|
||||
--text: rgba(43, 29, 22, 0.94);
|
||||
--muted: rgba(67, 51, 44, 0.78);
|
||||
--muted2: rgba(67, 51, 44, 0.64);
|
||||
--link: #a2522c;
|
||||
--accent: #c07046;
|
||||
--ok: #1f7a56;
|
||||
--bad: #b42318;
|
||||
--warn: #b45309;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
|
||||
}}
|
||||
|
||||
* {{ box-sizing: border-box; }}
|
||||
html, body {{ height: 100%; }}
|
||||
body {{
|
||||
margin: 0;
|
||||
font-family: var(--sans);
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(1000px 620px at 12% 8%, rgba(192, 112, 70, 0.18), transparent 58%),
|
||||
radial-gradient(900px 560px at 88% 0%, rgba(162, 82, 44, 0.14), transparent 60%),
|
||||
radial-gradient(760px 760px at 60% 92%, rgba(31, 122, 86, 0.10), transparent 58%),
|
||||
var(--bg);
|
||||
}}
|
||||
|
||||
a {{ color: var(--link); text-decoration: none; }}
|
||||
a:hover {{ text-decoration: underline; }}
|
||||
|
||||
.wrap {{
|
||||
max-width: 1020px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 18px 54px;
|
||||
}}
|
||||
@media (max-width: 520px) {{
|
||||
.wrap {{ padding: 20px 14px 44px; }}
|
||||
}}
|
||||
|
||||
.hero {{
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, rgba(255,250,243,0.95), rgba(255,250,243,0.74));
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 30px rgba(40, 24, 16, 0.10);
|
||||
padding: 16px 16px 14px;
|
||||
}}
|
||||
.hero h1 {{
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: 0.2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}}
|
||||
.sub {{
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
font-size: 13px;
|
||||
}}
|
||||
|
||||
.badge {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card-2);
|
||||
font-size: 12px;
|
||||
color: rgba(43, 29, 22, 0.88);
|
||||
white-space: nowrap;
|
||||
}}
|
||||
.dot {{
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--ok);
|
||||
box-shadow: 0 0 0 4px rgba(31, 122, 86, 0.12);
|
||||
}}
|
||||
.dot.bad {{
|
||||
background: var(--bad);
|
||||
box-shadow: 0 0 0 4px rgba(180, 35, 24, 0.10);
|
||||
}}
|
||||
|
||||
.chips {{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}}
|
||||
.chip {{
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card-2);
|
||||
font-size: 12px;
|
||||
color: rgba(43, 29, 22, 0.86);
|
||||
white-space: nowrap;
|
||||
}}
|
||||
|
||||
.grid {{
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}}
|
||||
@media (min-width: 860px) {{
|
||||
.grid {{ grid-template-columns: 1fr 1fr; }}
|
||||
}}
|
||||
|
||||
.card {{
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
border-radius: 14px;
|
||||
padding: 16px 16px 14px;
|
||||
box-shadow: 0 10px 30px rgba(40, 24, 16, 0.06);
|
||||
}}
|
||||
@media (max-width: 520px) {{
|
||||
.card {{ padding: 14px 14px 12px; }}
|
||||
}}
|
||||
.card h2 {{
|
||||
font-size: 15px;
|
||||
margin: 0 0 10px;
|
||||
letter-spacing: 0.2px;
|
||||
}}
|
||||
|
||||
.kpi {{
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}}
|
||||
@media (min-width: 980px) {{
|
||||
.kpi {{ grid-template-columns: 1fr 1fr 1fr 1fr; }}
|
||||
}}
|
||||
.k {{
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(43, 29, 22, 0.03);
|
||||
border-radius: 12px;
|
||||
padding: 10px 10px 9px;
|
||||
min-height: 66px;
|
||||
}}
|
||||
.k .label {{
|
||||
color: var(--muted2);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}}
|
||||
.k .val {{
|
||||
margin-top: 6px;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.2px;
|
||||
font-weight: 650;
|
||||
}}
|
||||
.k .hint {{
|
||||
margin-top: 2px;
|
||||
color: var(--muted2);
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
}}
|
||||
|
||||
.row {{
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}}
|
||||
select {{
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card-2);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: rgba(43, 29, 22, 0.90);
|
||||
outline: none;
|
||||
}}
|
||||
.pie-wrap {{
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
margin-top: 10px;
|
||||
}}
|
||||
@media (min-width: 700px) {{
|
||||
.pie-wrap {{ grid-template-columns: 220px 1fr; align-items: center; }}
|
||||
}}
|
||||
.pie {{
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: conic-gradient(#ddd 0deg, #eee 360deg);
|
||||
box-shadow: inset 0 0 0 8px rgba(255, 250, 243, 0.8);
|
||||
}}
|
||||
.legend {{
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}}
|
||||
.li {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: rgba(43, 29, 22, 0.88);
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}}
|
||||
.sw {{
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(58, 38, 26, 0.18);
|
||||
background: #ddd;
|
||||
flex: 0 0 auto;
|
||||
}}
|
||||
.muted {{ color: var(--muted2); }}
|
||||
.footer {{
|
||||
margin-top: 14px;
|
||||
color: var(--muted2);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="hero">
|
||||
<h1>
|
||||
Status · Random Mage
|
||||
<span class="badge" title="当前页面也是一个健康信号(可访问说明 API 在线)">
|
||||
<span class="dot {'bad' if api_status != 'ok' else ''}"></span>
|
||||
<strong style="font-weight:650;">{api_status.upper()}</strong>
|
||||
<span class="muted">{api_status_code}</span>
|
||||
</span>
|
||||
</h1>
|
||||
<div class="sub">
|
||||
最后更新:<span class="muted">{updated_at}</span> · JSON:<a href="{json_url}">{json_url}</a>
|
||||
</div>
|
||||
<div class="chips" aria-label="quick-links">
|
||||
<a class="chip" href="{random_url}"><strong>/random</strong> 随机出图</a>
|
||||
<a class="chip" href="{docs_url}"><strong>/docs</strong> 使用文档</a>
|
||||
<a class="chip" href="{admin_url}"><strong>/admin</strong> 管理后台</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<h2>图库概览</h2>
|
||||
<div class="kpi">
|
||||
<div class="k"><div class="label">总图片数</div><div class="val">{images_total}</div><div class="hint">images</div></div>
|
||||
<div class="k"><div class="label">总作品数</div><div class="val">{illust_total}</div><div class="hint">DISTINCT illust_id</div></div>
|
||||
<div class="k"><div class="label">总作者数</div><div class="val">{authors_total}</div><div class="hint">DISTINCT user_id</div></div>
|
||||
<div class="k"><div class="label">更多统计</div><div class="val">—</div><div class="hint"><a href="{admin_url}">后台首页</a></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>请求概览(/random)</h2>
|
||||
<div class="kpi">
|
||||
<div class="k"><div class="label">总请求数</div><div class="val">{random_total}</div><div class="hint">跨重启持久化</div></div>
|
||||
<div class="k"><div class="label">实时并发</div><div class="val">{random_in_flight}</div><div class="hint">in_flight</div></div>
|
||||
<div class="k"><div class="label">近 60 秒请求</div><div class="val">{last_window_requests}</div><div class="hint">window</div></div>
|
||||
<div class="k"><div class="label">近 60 秒成功率</div><div class="val">{last_window_success_rate*100:.1f}%</div><div class="hint">2xx/3xx</div></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card" style="margin-top: 14px;">
|
||||
<h2>占比图</h2>
|
||||
<div class="row">
|
||||
<span class="muted">选择维度:</span>
|
||||
<select id="metric">
|
||||
<option value="r18">R18 / 非 R18</option>
|
||||
<option value="ai">AI / 非 AI</option>
|
||||
<option value="loli">萝莉 / 非萝莉</option>
|
||||
</select>
|
||||
<span class="muted" id="metricNote"></span>
|
||||
</div>
|
||||
<div class="pie-wrap">
|
||||
<div class="pie" id="pie" aria-label="pie"></div>
|
||||
<div class="legend" id="legend" aria-label="legend"></div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
说明:占比统计基于 <code>status=1</code> 的图片;未补全字段会落到 unknown。<br/>
|
||||
/status 只读展示,不需要登录。
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const DATA = {json.dumps(payload, ensure_ascii=False, separators=(",", ":"))};
|
||||
|
||||
function fmtInt(n) {{
|
||||
const x = Number(n || 0);
|
||||
if (!Number.isFinite(x)) return "0";
|
||||
return String(Math.trunc(x));
|
||||
}}
|
||||
|
||||
function renderPie(slices) {{
|
||||
const pie = document.getElementById("pie");
|
||||
const legend = document.getElementById("legend");
|
||||
const total = slices.reduce((s, it) => s + (Number(it.value) || 0), 0) || 0;
|
||||
|
||||
if (!total) {{
|
||||
pie.style.background = "conic-gradient(#ddd 0deg, #eee 360deg)";
|
||||
legend.innerHTML = '<div class="muted">暂无数据</div>';
|
||||
return;
|
||||
}}
|
||||
|
||||
let acc = 0;
|
||||
const stops = [];
|
||||
for (const it of slices) {{
|
||||
const v = Math.max(0, Number(it.value) || 0);
|
||||
const a0 = (acc / total) * 360;
|
||||
acc += v;
|
||||
const a1 = (acc / total) * 360;
|
||||
stops.push(`${{it.color}} ${{a0}}deg ${{a1}}deg`);
|
||||
}}
|
||||
pie.style.background = `conic-gradient(${{stops.join(", ")}})`;
|
||||
|
||||
legend.innerHTML = slices.map(it => {{
|
||||
const v = Math.max(0, Number(it.value) || 0);
|
||||
const pct = total > 0 ? (v / total) * 100 : 0;
|
||||
return `
|
||||
<div class="li">
|
||||
<span class="sw" style="background:${{it.color}}"></span>
|
||||
<div><strong>${{it.label}}</strong> <span class="muted">${{fmtInt(v)}} · ${{pct.toFixed(1)}}%</span></div>
|
||||
</div>
|
||||
`;
|
||||
}}).join("");
|
||||
}}
|
||||
|
||||
function pickMetric(metric) {{
|
||||
const note = document.getElementById("metricNote");
|
||||
const bd = (DATA.breakdown || {{}});
|
||||
if (metric === "ai") {{
|
||||
note.textContent = "(ai_type)";
|
||||
const ai = bd.ai || {{}};
|
||||
renderPie([
|
||||
{{ label: "AI", value: ai.ai || 0, color: "#c07046" }},
|
||||
{{ label: "非 AI", value: ai.non_ai || 0, color: "#1f7a56" }},
|
||||
{{ label: "未知", value: ai.unknown || 0, color: "#8f857c" }},
|
||||
]);
|
||||
return;
|
||||
}}
|
||||
if (metric === "loli") {{
|
||||
const l = bd.loli || {{}};
|
||||
note.textContent = (l.tag_found === false) ? "(tags 表中未找到 loli 标签)" : "(标签:loli)";
|
||||
renderPie([
|
||||
{{ label: "萝莉", value: l.loli || 0, color: "#c07046" }},
|
||||
{{ label: "非萝莉", value: l.non_loli || 0, color: "#1f7a56" }},
|
||||
]);
|
||||
return;
|
||||
}}
|
||||
// default: r18
|
||||
note.textContent = "(x_restrict)";
|
||||
const r = bd.r18 || {{}};
|
||||
renderPie([
|
||||
{{ label: "R18", value: r.r18 || 0, color: "#b42318" }},
|
||||
{{ label: "非 R18", value: r.safe || 0, color: "#1f7a56" }},
|
||||
{{ label: "未知", value: r.unknown || 0, color: "#8f857c" }},
|
||||
]);
|
||||
}}
|
||||
|
||||
const sel = document.getElementById("metric");
|
||||
pickMetric(sel.value);
|
||||
sel.addEventListener("change", () => pickMetric(sel.value));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@router.get("/status.json", include_in_schema=False)
|
||||
async def status_json(request: Request) -> JSONResponse:
|
||||
rid = get_or_create_request_id(request)
|
||||
set_request_id_on_state(request, rid)
|
||||
|
||||
engine = request.app.state.engine
|
||||
api_status = "ok"
|
||||
api_status_code = 200
|
||||
payload: dict[str, Any] = {"api_status": api_status, "api_status_code": api_status_code, "updated_at": iso_utc_ms()}
|
||||
|
||||
stats = getattr(request.app.state, "random_request_stats", None)
|
||||
if stats is not None:
|
||||
payload["random"] = asdict(await stats.snapshot())
|
||||
else:
|
||||
payload["random"] = {
|
||||
"total_requests": 0,
|
||||
"total_ok": 0,
|
||||
"total_error": 0,
|
||||
"in_flight": 0,
|
||||
"window_seconds": 60,
|
||||
"last_window_requests": 0,
|
||||
"last_window_ok": 0,
|
||||
"last_window_error": 0,
|
||||
"last_window_success_rate": 0.0,
|
||||
}
|
||||
|
||||
try:
|
||||
payload.update(await _query_gallery_stats(engine))
|
||||
except Exception as exc:
|
||||
api_status = "degraded"
|
||||
api_status_code = 503
|
||||
payload["api_status"] = api_status
|
||||
payload["api_status_code"] = api_status_code
|
||||
payload["error"] = {"type": type(exc).__name__, "message": str(exc)}
|
||||
|
||||
resp = JSONResponse(status_code=int(api_status_code), content={"ok": api_status_code == 200, "data": payload, "request_id": rid})
|
||||
set_request_id_header(resp, rid)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/status", include_in_schema=False)
|
||||
async def status_page(request: Request) -> HTMLResponse:
|
||||
rid = get_or_create_request_id(request)
|
||||
set_request_id_on_state(request, rid)
|
||||
|
||||
engine = request.app.state.engine
|
||||
api_status = "ok"
|
||||
api_status_code = 200
|
||||
|
||||
payload: dict[str, Any] = {"api_status": api_status, "api_status_code": api_status_code, "updated_at": iso_utc_ms()}
|
||||
|
||||
stats = getattr(request.app.state, "random_request_stats", None)
|
||||
if stats is not None:
|
||||
payload["random"] = asdict(await stats.snapshot())
|
||||
|
||||
try:
|
||||
payload.update(await _query_gallery_stats(engine))
|
||||
except Exception as exc:
|
||||
api_status = "degraded"
|
||||
api_status_code = 503
|
||||
payload["api_status"] = api_status
|
||||
payload["api_status_code"] = api_status_code
|
||||
payload["error"] = {"type": type(exc).__name__, "message": str(exc)}
|
||||
|
||||
html = _build_status_html(
|
||||
base_url=str(getattr(request, "base_url", "") or "").rstrip("/"),
|
||||
status_code=int(api_status_code),
|
||||
payload=payload,
|
||||
)
|
||||
resp = HTMLResponse(content=html, status_code=int(api_status_code), headers={"Cache-Control": "no-store"})
|
||||
set_request_id_header(resp, rid)
|
||||
return resp
|
||||
@@ -0,0 +1,352 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.core.request_id import get_or_create_request_id, set_request_id_header, set_request_id_on_state
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _build_wtf_html(*, base_url: str) -> str:
|
||||
base = (base_url or "").rstrip("/")
|
||||
if not base:
|
||||
base = ""
|
||||
|
||||
def u(path: str) -> str:
|
||||
path_norm = (path or "").strip()
|
||||
if not path_norm.startswith("/"):
|
||||
path_norm = "/" + path_norm
|
||||
return f"{base}{path_norm}"
|
||||
|
||||
docs_url = u("/docs")
|
||||
status_url = u("/status")
|
||||
random_url = u("/random")
|
||||
|
||||
return f"""<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>WTF · 瀑布流 · Random Mage</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #f4efe6;
|
||||
--card: rgba(255, 250, 243, 0.80);
|
||||
--card-2: rgba(255, 250, 243, 0.92);
|
||||
--border: rgba(58, 38, 26, 0.14);
|
||||
--text: rgba(43, 29, 22, 0.94);
|
||||
--muted: rgba(67, 51, 44, 0.78);
|
||||
--muted2: rgba(67, 51, 44, 0.64);
|
||||
--link: #a2522c;
|
||||
--accent: #c07046;
|
||||
--ok: #1f7a56;
|
||||
--bad: #b42318;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
|
||||
}}
|
||||
|
||||
* {{ box-sizing: border-box; }}
|
||||
html, body {{ height: 100%; }}
|
||||
body {{
|
||||
margin: 0;
|
||||
font-family: var(--sans);
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(1000px 620px at 12% 8%, rgba(192, 112, 70, 0.18), transparent 58%),
|
||||
radial-gradient(900px 560px at 88% 0%, rgba(162, 82, 44, 0.14), transparent 60%),
|
||||
radial-gradient(760px 760px at 60% 92%, rgba(31, 122, 86, 0.10), transparent 58%),
|
||||
var(--bg);
|
||||
}}
|
||||
|
||||
a {{ color: var(--link); text-decoration: none; }}
|
||||
a:hover {{ text-decoration: underline; }}
|
||||
|
||||
.wrap {{
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 16px 44px;
|
||||
}}
|
||||
@media (max-width: 520px) {{
|
||||
.wrap {{ padding: 18px 12px 40px; }}
|
||||
}}
|
||||
|
||||
.top {{
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, rgba(255,250,243,0.95), rgba(255,250,243,0.74));
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 30px rgba(40, 24, 16, 0.10);
|
||||
padding: 14px 14px 12px;
|
||||
}}
|
||||
.top h1 {{
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}}
|
||||
.sub {{
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
font-size: 13px;
|
||||
}}
|
||||
.chips {{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}}
|
||||
.chip {{
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card-2);
|
||||
font-size: 12px;
|
||||
color: rgba(43, 29, 22, 0.86);
|
||||
white-space: nowrap;
|
||||
}}
|
||||
.row {{
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}}
|
||||
.btn {{
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card-2);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: rgba(43, 29, 22, 0.90);
|
||||
}}
|
||||
.btn:hover {{ filter: brightness(0.98); }}
|
||||
.muted {{ color: var(--muted2); }}
|
||||
code {{
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
background: rgba(43, 29, 22, 0.06);
|
||||
border: 1px solid rgba(58, 38, 26, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 1px 6px;
|
||||
}}
|
||||
|
||||
.feed {{
|
||||
margin-top: 14px;
|
||||
column-width: 320px;
|
||||
column-gap: 14px;
|
||||
}}
|
||||
@media (max-width: 520px) {{
|
||||
.feed {{ column-width: auto; column-count: 1; column-gap: 0; }}
|
||||
}}
|
||||
.item {{
|
||||
break-inside: avoid;
|
||||
margin: 0 0 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(40, 24, 16, 0.06);
|
||||
}}
|
||||
.item .meta {{
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: rgba(67, 51, 44, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}}
|
||||
.item img {{
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
opacity: 0;
|
||||
transform: translateY(3px);
|
||||
transition: opacity 180ms ease, transform 220ms ease;
|
||||
}}
|
||||
.item.loaded img {{
|
||||
opacity: 1;
|
||||
transform: translateY(0px);
|
||||
}}
|
||||
.item.fail {{
|
||||
border-color: rgba(180, 35, 24, 0.26);
|
||||
}}
|
||||
.item.fail .meta {{
|
||||
color: rgba(180, 35, 24, 0.86);
|
||||
}}
|
||||
.sentinel {{
|
||||
text-align: center;
|
||||
padding: 18px 0 0;
|
||||
color: var(--muted2);
|
||||
font-size: 12px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="top">
|
||||
<h1>WTF · 瀑布流随机</h1>
|
||||
<div class="sub">
|
||||
进入即自动加载,往下滑继续出图。<span class="muted">默认开启 <code>adaptive=1</code>(移动端偏竖图/更低像素门槛,PC 偏横图/更高门槛;不覆盖你显式传入的过滤参数)。</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<a class="chip" href="{docs_url}"><strong>/docs</strong> 参数说明</a>
|
||||
<a class="chip" href="{status_url}"><strong>/status</strong> 运行状态</a>
|
||||
<a class="chip" href="{random_url}"><strong>/random</strong> 单张随机</a>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn" id="toggle">暂停加载</button>
|
||||
<span class="muted" id="info"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="feed" class="feed" aria-label="feed"></div>
|
||||
<div id="sentinel" class="sentinel">加载中…</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const feed = document.getElementById("feed");
|
||||
const sentinel = document.getElementById("sentinel");
|
||||
const info = document.getElementById("info");
|
||||
const toggle = document.getElementById("toggle");
|
||||
|
||||
const baseParams = new URLSearchParams(window.location.search);
|
||||
baseParams.delete("format");
|
||||
baseParams.delete("redirect");
|
||||
baseParams.delete("t");
|
||||
if (!baseParams.has("adaptive")) baseParams.set("adaptive", "1");
|
||||
baseParams.set("redirect", "1");
|
||||
|
||||
const baseQuery = baseParams.toString();
|
||||
info.textContent = baseQuery ? `当前过滤: ?${{baseQuery}}` : "当前过滤: (无)";
|
||||
|
||||
let paused = false;
|
||||
let counter = 0;
|
||||
let inflight = 0;
|
||||
|
||||
function buildUrl() {{
|
||||
const p = new URLSearchParams(baseParams);
|
||||
p.set("t", String(Date.now()) + "_" + String(counter++));
|
||||
return "/random?" + p.toString();
|
||||
}}
|
||||
|
||||
function addItem() {{
|
||||
const item = document.createElement("div");
|
||||
item.className = "item";
|
||||
const img = document.createElement("img");
|
||||
img.loading = "lazy";
|
||||
img.decoding = "async";
|
||||
img.referrerPolicy = "no-referrer";
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "meta";
|
||||
meta.innerHTML = `<span class="muted">#${{String(counter)}}</span><span class="muted">加载中…</span>`;
|
||||
|
||||
let tries = 0;
|
||||
const setSrc = () => {{
|
||||
meta.children[1].textContent = tries > 0 ? `重试 ${{tries}}…` : "加载中…";
|
||||
img.src = buildUrl();
|
||||
}};
|
||||
|
||||
img.onload = () => {{
|
||||
item.classList.add("loaded");
|
||||
meta.children[1].textContent = "OK";
|
||||
inflight = Math.max(0, inflight - 1);
|
||||
updateSentinel();
|
||||
maybePrefill();
|
||||
}};
|
||||
img.onerror = () => {{
|
||||
tries += 1;
|
||||
if (tries <= 3) {{
|
||||
setTimeout(setSrc, 200 * tries);
|
||||
return;
|
||||
}}
|
||||
item.classList.add("fail");
|
||||
meta.children[1].textContent = "加载失败";
|
||||
inflight = Math.max(0, inflight - 1);
|
||||
updateSentinel();
|
||||
maybePrefill();
|
||||
}};
|
||||
|
||||
inflight += 1;
|
||||
updateSentinel();
|
||||
item.appendChild(img);
|
||||
item.appendChild(meta);
|
||||
feed.appendChild(item);
|
||||
setSrc();
|
||||
}}
|
||||
|
||||
function batch(n) {{
|
||||
const count = Math.max(1, Number(n) || 1);
|
||||
for (let i = 0; i < count; i++) addItem();
|
||||
}}
|
||||
|
||||
function isMobile() {{
|
||||
return window.matchMedia && window.matchMedia("(max-width: 520px)").matches;
|
||||
}}
|
||||
|
||||
function updateSentinel() {{
|
||||
const items = feed.children.length;
|
||||
const status = paused ? "已暂停" : "加载中";
|
||||
sentinel.textContent = `${{status}} · 已渲染 ${{items}} 张 · in-flight ${{inflight}}`;
|
||||
}}
|
||||
|
||||
function maybePrefill() {{
|
||||
if (paused) return;
|
||||
const items = feed.children.length;
|
||||
const target = isMobile() ? 10 : 18;
|
||||
if (items < target) {{
|
||||
batch(target - items);
|
||||
}}
|
||||
}}
|
||||
|
||||
function loadMore() {{
|
||||
if (paused) return;
|
||||
const maxInflight = isMobile() ? 10 : 18;
|
||||
if (inflight >= maxInflight) return;
|
||||
const n = isMobile() ? 6 : 10;
|
||||
batch(n);
|
||||
}}
|
||||
|
||||
toggle.addEventListener("click", () => {{
|
||||
paused = !paused;
|
||||
toggle.textContent = paused ? "继续加载" : "暂停加载";
|
||||
updateSentinel();
|
||||
if (!paused) {{
|
||||
maybePrefill();
|
||||
loadMore();
|
||||
}}
|
||||
}});
|
||||
|
||||
const io = new IntersectionObserver((entries) => {{
|
||||
for (const e of entries) {{
|
||||
if (e.isIntersecting) loadMore();
|
||||
}}
|
||||
}}, {{ root: null, rootMargin: "900px 0px", threshold: 0 }});
|
||||
io.observe(sentinel);
|
||||
|
||||
// Initial fill.
|
||||
maybePrefill();
|
||||
loadMore();
|
||||
updateSentinel();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@router.get("/wtf", include_in_schema=False)
|
||||
async def wtf_page(request: Request) -> HTMLResponse:
|
||||
rid = get_or_create_request_id(request)
|
||||
set_request_id_on_state(request, rid)
|
||||
html = _build_wtf_html(base_url=str(getattr(request, "base_url", "") or "").rstrip("/"))
|
||||
resp = HTMLResponse(content=html, status_code=200, headers={"Cache-Control": "no-store"})
|
||||
set_request_id_header(resp, rid)
|
||||
return resp
|
||||
|
||||
@@ -37,6 +37,7 @@ class Settings:
|
||||
public_api_key_required: bool
|
||||
public_api_key_rpm: int
|
||||
public_api_key_burst: int
|
||||
random_totals_persist_interval_seconds: int
|
||||
|
||||
@property
|
||||
def is_prod(self) -> bool:
|
||||
@@ -168,6 +169,14 @@ def load_settings(env: Mapping[str, str] | None = None) -> Settings:
|
||||
public_api_key_burst = 0
|
||||
public_api_key_burst = max(0, min(int(public_api_key_burst), 10_000_000))
|
||||
|
||||
try:
|
||||
random_totals_persist_interval_seconds = int(
|
||||
_get(env, "RANDOM_TOTALS_PERSIST_INTERVAL_SECONDS", "15") or "15"
|
||||
)
|
||||
except Exception:
|
||||
random_totals_persist_interval_seconds = 15
|
||||
random_totals_persist_interval_seconds = max(2, min(int(random_totals_persist_interval_seconds), 300))
|
||||
|
||||
settings = Settings(
|
||||
app_env=app_env,
|
||||
database_url=database_url,
|
||||
@@ -187,6 +196,7 @@ def load_settings(env: Mapping[str, str] | None = None) -> Settings:
|
||||
public_api_key_required=public_api_key_required,
|
||||
public_api_key_rpm=public_api_key_rpm,
|
||||
public_api_key_burst=public_api_key_burst,
|
||||
random_totals_persist_interval_seconds=random_totals_persist_interval_seconds,
|
||||
)
|
||||
|
||||
if settings.is_prod:
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.core.time import iso_utc_ms
|
||||
from app.core.runtime_settings import set_runtime_setting
|
||||
from app.db.session import with_sqlite_busy_retry
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
RANDOM_TOTALS_KEY = "stats.random.total"
|
||||
|
||||
|
||||
def _as_nonneg_int(value: Any) -> int:
|
||||
try:
|
||||
i = int(value)
|
||||
except Exception:
|
||||
return 0
|
||||
return i if i > 0 else 0
|
||||
|
||||
|
||||
async def load_persisted_random_totals(engine: AsyncEngine) -> dict[str, int]:
|
||||
sql = "SELECT value_json FROM runtime_settings WHERE key = ?;"
|
||||
|
||||
async def _op() -> str | None:
|
||||
async with engine.connect() as conn:
|
||||
row = (await conn.exec_driver_sql(sql, (RANDOM_TOTALS_KEY,))).fetchone()
|
||||
return str(row[0]) if row is not None and row[0] is not None else None
|
||||
|
||||
try:
|
||||
raw = await with_sqlite_busy_retry(_op)
|
||||
except Exception:
|
||||
return {"total_requests": 0, "total_ok": 0, "total_error": 0}
|
||||
|
||||
if raw is None:
|
||||
return {"total_requests": 0, "total_ok": 0, "total_error": 0}
|
||||
|
||||
try:
|
||||
data = json.loads(str(raw))
|
||||
except Exception:
|
||||
return {"total_requests": 0, "total_ok": 0, "total_error": 0}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return {"total_requests": 0, "total_ok": 0, "total_error": 0}
|
||||
|
||||
return {
|
||||
"total_requests": _as_nonneg_int(data.get("total_requests")),
|
||||
"total_ok": _as_nonneg_int(data.get("total_ok")),
|
||||
"total_error": _as_nonneg_int(data.get("total_error")),
|
||||
}
|
||||
|
||||
|
||||
async def persist_random_totals(
|
||||
engine: AsyncEngine,
|
||||
*,
|
||||
total_requests: int,
|
||||
total_ok: int,
|
||||
total_error: int,
|
||||
source: str,
|
||||
) -> None:
|
||||
value = {
|
||||
"total_requests": max(0, int(total_requests)),
|
||||
"total_ok": max(0, int(total_ok)),
|
||||
"total_error": max(0, int(total_error)),
|
||||
"updated_at": iso_utc_ms(),
|
||||
"source": str(source or "unknown"),
|
||||
}
|
||||
try:
|
||||
await set_runtime_setting(
|
||||
engine,
|
||||
key=RANDOM_TOTALS_KEY,
|
||||
value=value,
|
||||
description="persisted /random totals",
|
||||
updated_by=f"system:{source}",
|
||||
)
|
||||
except Exception as exc:
|
||||
try:
|
||||
log.warning("persist_random_totals_failed err=%s", f"{type(exc).__name__}: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -32,6 +32,16 @@ class RandomRequestStats:
|
||||
# (monotonic_seconds, ok)
|
||||
self._events: deque[tuple[float, bool]] = deque()
|
||||
|
||||
async def set_totals(self, *, total_requests: int, total_ok: int, total_error: int) -> None:
|
||||
tr = max(0, int(total_requests))
|
||||
tok = max(0, int(total_ok))
|
||||
terr = max(0, int(total_error))
|
||||
async with self._lock:
|
||||
self._total_requests = tr
|
||||
self._total_ok = tok
|
||||
self._total_error = terr
|
||||
self._in_flight = max(0, int(self._in_flight))
|
||||
|
||||
def _purge(self, *, now_m: float) -> None:
|
||||
cutoff = float(now_m) - float(self._window_s)
|
||||
while self._events:
|
||||
@@ -80,4 +90,3 @@ class RandomRequestStats:
|
||||
last_window_error=int(last_error),
|
||||
last_window_success_rate=float(rate),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import distinct, func, or_, select
|
||||
from sqlalchemy import and_, distinct, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models.image_tags import ImageTag
|
||||
@@ -36,45 +36,73 @@ def _orientation_where_clause(*, orientation: int | None) -> object | None:
|
||||
return Image.orientation == orientation_i
|
||||
|
||||
|
||||
def _clean_tag_names(values: Sequence[str] | None) -> list[str]:
|
||||
def _clean_tag_groups(values: Sequence[str] | None) -> list[list[str]]:
|
||||
if not values:
|
||||
return []
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for v in values:
|
||||
name = str(v or "").strip()
|
||||
if not name:
|
||||
|
||||
groups: list[list[str]] = []
|
||||
seen_groups: set[str] = set()
|
||||
for raw in values:
|
||||
expr = str(raw or "").strip()
|
||||
if not expr:
|
||||
continue
|
||||
if name in seen:
|
||||
|
||||
group: list[str] = []
|
||||
seen_in_group: set[str] = set()
|
||||
for part in expr.split("|"):
|
||||
name = part.strip()
|
||||
if not name or name in seen_in_group:
|
||||
continue
|
||||
seen_in_group.add(name)
|
||||
group.append(name)
|
||||
|
||||
if not group:
|
||||
continue
|
||||
seen.add(name)
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
# Group identity ignores order (girl|boy == boy|girl).
|
||||
key = "\x1f".join(sorted(group))
|
||||
if key in seen_groups:
|
||||
continue
|
||||
seen_groups.add(key)
|
||||
groups.append(group)
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
def _included_tags_where_clause(*, tag_names: Sequence[str]) -> object | None:
|
||||
names = _clean_tag_names(tag_names)
|
||||
if not names:
|
||||
groups = _clean_tag_groups(tag_names)
|
||||
if not groups:
|
||||
return None
|
||||
subq = (
|
||||
select(ImageTag.image_id)
|
||||
.join(Tag, Tag.id == ImageTag.tag_id)
|
||||
.where(Tag.name.in_(names))
|
||||
.group_by(ImageTag.image_id)
|
||||
.having(func.count(distinct(Tag.name)) == len(names))
|
||||
)
|
||||
return Image.id.in_(subq)
|
||||
|
||||
clauses: list[object] = []
|
||||
for group in groups:
|
||||
tag_ids = select(Tag.id).where(Tag.name.in_(group))
|
||||
subq = select(distinct(ImageTag.image_id)).where(ImageTag.tag_id.in_(tag_ids))
|
||||
clauses.append(Image.id.in_(subq))
|
||||
|
||||
if not clauses:
|
||||
return None
|
||||
return clauses[0] if len(clauses) == 1 else and_(*clauses)
|
||||
|
||||
|
||||
def _excluded_tags_where_clause(*, tag_names: Sequence[str]) -> object | None:
|
||||
names = _clean_tag_names(tag_names)
|
||||
groups = _clean_tag_groups(tag_names)
|
||||
if not groups:
|
||||
return None
|
||||
|
||||
names: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for group in groups:
|
||||
for name in group:
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
names.append(name)
|
||||
|
||||
if not names:
|
||||
return None
|
||||
subq = (
|
||||
select(ImageTag.image_id)
|
||||
.join(Tag, Tag.id == ImageTag.tag_id)
|
||||
.where(Tag.name.in_(names))
|
||||
)
|
||||
tag_ids = select(Tag.id).where(Tag.name.in_(names))
|
||||
subq = select(distinct(ImageTag.image_id)).where(ImageTag.tag_id.in_(tag_ids))
|
||||
return Image.id.not_in(subq)
|
||||
|
||||
|
||||
|
||||
+79
-1
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
@@ -10,6 +11,8 @@ from app.api.admin.router import router as admin_router
|
||||
from app.api.metrics import router as metrics_router
|
||||
from app.api.public.healthz import router as healthz_router
|
||||
from app.api.public.docs_page import router as docs_page_router
|
||||
from app.api.public.status_page import router as status_page_router
|
||||
from app.api.public.wtf_page import router as wtf_page_router
|
||||
from app.api.public.authors import router as authors_router
|
||||
from app.api.public.images import router as images_router
|
||||
from app.api.public.legacy import router as legacy_router
|
||||
@@ -21,6 +24,7 @@ from app.core.api_keys import ApiKeyAuthConfig, ApiKeyAuthenticator, ApiKeyRateL
|
||||
from app.core.errors import ApiError, ErrorCode, json_error_response
|
||||
from app.core.logging import configure_logging, get_logger
|
||||
from app.core.metrics import observe_random_result
|
||||
from app.core.random_request_persistence import load_persisted_random_totals, persist_random_totals
|
||||
from app.core.random_request_stats import RandomRequestStats
|
||||
from app.core.request_id import build_request_id_middleware, get_or_create_request_id, set_request_id_on_state
|
||||
from app.core.security import decode_jwt, parse_bearer_token
|
||||
@@ -81,6 +85,48 @@ def create_app() -> FastAPI:
|
||||
app.state.api_key_limiter = ApiKeyRateLimiter(rpm=int(api_key_cfg.rpm), burst=int(api_key_cfg.burst))
|
||||
app.state.random_request_stats = RandomRequestStats(window_seconds=60)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _startup() -> None: # type: ignore[no-redef]
|
||||
engine = getattr(app.state, "engine", None)
|
||||
stats = getattr(app.state, "random_request_stats", None)
|
||||
if engine is None or stats is None:
|
||||
return
|
||||
|
||||
try:
|
||||
totals = await load_persisted_random_totals(engine)
|
||||
await stats.set_totals(
|
||||
total_requests=int(totals.get("total_requests", 0) or 0),
|
||||
total_ok=int(totals.get("total_ok", 0) or 0),
|
||||
total_error=int(totals.get("total_error", 0) or 0),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
interval_s = float((settings.random_totals_persist_interval_seconds or 0) or 15)
|
||||
except Exception:
|
||||
interval_s = 15.0
|
||||
interval_s = max(2.0, min(float(interval_s), 300.0))
|
||||
|
||||
async def _loop() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(float(interval_s))
|
||||
try:
|
||||
snap = await stats.snapshot()
|
||||
await persist_random_totals(
|
||||
engine,
|
||||
total_requests=int(snap.total_requests),
|
||||
total_ok=int(snap.total_ok),
|
||||
total_error=int(snap.total_error),
|
||||
source="api",
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
app.state.random_totals_persist_task = asyncio.create_task(_loop())
|
||||
|
||||
@app.middleware("http")
|
||||
async def _public_api_key_middleware(request: Request, call_next): # type: ignore[no-redef]
|
||||
if not bool(settings.public_api_key_required):
|
||||
@@ -89,7 +135,7 @@ def create_app() -> FastAPI:
|
||||
path = request.url.path
|
||||
if path.startswith("/admin") or path.startswith("/metrics"):
|
||||
return await call_next(request)
|
||||
if path in {"/healthz", "/version", "/openapi.json", "/docs", "/api/docs", "/api/redoc"}:
|
||||
if path in {"/healthz", "/version", "/openapi.json", "/docs", "/status", "/status.json", "/wtf", "/api/docs", "/api/redoc"}:
|
||||
return await call_next(request)
|
||||
|
||||
rid = get_or_create_request_id(request)
|
||||
@@ -231,6 +277,36 @@ def create_app() -> FastAPI:
|
||||
@app.on_event("shutdown")
|
||||
async def _shutdown() -> None: # type: ignore[no-redef]
|
||||
engine = getattr(app.state, "engine", None)
|
||||
stats = getattr(app.state, "random_request_stats", None)
|
||||
if engine is not None and stats is not None:
|
||||
try:
|
||||
snap = await asyncio.wait_for(stats.snapshot(), timeout=1.0)
|
||||
await asyncio.wait_for(
|
||||
persist_random_totals(
|
||||
engine,
|
||||
total_requests=int(snap.total_requests),
|
||||
total_ok=int(snap.total_ok),
|
||||
total_error=int(snap.total_error),
|
||||
source="shutdown",
|
||||
),
|
||||
timeout=2.0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
task = getattr(app.state, "random_totals_persist_task", None)
|
||||
if task is not None:
|
||||
try:
|
||||
task.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=2.0)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if engine is not None:
|
||||
await engine.dispose()
|
||||
|
||||
@@ -240,6 +316,8 @@ def create_app() -> FastAPI:
|
||||
|
||||
app.include_router(healthz_router)
|
||||
app.include_router(docs_page_router)
|
||||
app.include_router(status_page_router)
|
||||
app.include_router(wtf_page_router)
|
||||
app.include_router(authors_router)
|
||||
app.include_router(images_router)
|
||||
app.include_router(legacy_router)
|
||||
|
||||
@@ -119,6 +119,59 @@ def test_included_tags_requires_all(tmp_path: Path) -> None:
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_included_tags_supports_or_groups(tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "included_or.db"
|
||||
engine = create_engine("sqlite+aiosqlite:///" + db_path.as_posix())
|
||||
|
||||
async def _run() -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
Session = create_sessionmaker(engine)
|
||||
async with Session() as session:
|
||||
cat = Tag(name="cat")
|
||||
dog = Tag(name="dog")
|
||||
session.add_all([cat, dog])
|
||||
|
||||
img_cat = Image(
|
||||
illust_id=30,
|
||||
page_index=0,
|
||||
ext="jpg",
|
||||
original_url="https://example.test/cat.jpg",
|
||||
proxy_path="/i/1.jpg",
|
||||
random_key=0.1,
|
||||
x_restrict=0,
|
||||
)
|
||||
img_dog = Image(
|
||||
illust_id=31,
|
||||
page_index=0,
|
||||
ext="jpg",
|
||||
original_url="https://example.test/dog.jpg",
|
||||
proxy_path="/i/2.jpg",
|
||||
random_key=0.2,
|
||||
x_restrict=0,
|
||||
)
|
||||
session.add_all([img_cat, img_dog])
|
||||
await session.flush()
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
ImageTag(image_id=img_cat.id, tag_id=cat.id),
|
||||
ImageTag(image_id=img_dog.id, tag_id=dog.id),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async with Session() as session:
|
||||
picked = await pick_random_image(session, r=0.0, included_tags=["cat|dog"])
|
||||
assert picked is not None
|
||||
assert int(picked.illust_id) in {30, 31}
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_excluded_tags_filters_images(tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "excluded_tags.db"
|
||||
engine = create_engine("sqlite+aiosqlite:///" + db_path.as_posix())
|
||||
@@ -170,4 +223,3 @@ def test_excluded_tags_filters_images(tmp_path: Path) -> None:
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
@@ -10,6 +10,8 @@ type NavItem = { key: string; label: string; external?: boolean; href?: string }
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ key: "/admin", label: "主页" },
|
||||
{ key: "docs", label: "使用文档", external: true, href: "/docs" },
|
||||
{ key: "status", label: "运行状态", external: true, href: "/status" },
|
||||
{ key: "wtf", label: "瀑布流", external: true, href: "/wtf" },
|
||||
{ key: "/admin/import", label: "导入链接" },
|
||||
{ key: "/admin/images", label: "图片管理" },
|
||||
{ key: "/admin/hydration", label: "补全管理" },
|
||||
|
||||
Reference in new issue
Block a user