增加/status状态页,tag参数请求增加or,总请求数持久化,增加瀑布流页面/wtf

This commit is contained in:
inliver233 committed 2026-02-24 02:48:39 +08:00
1 parent 638a2852f9
commit 6a843ceafd
11 files changed
+1271 -41

No files matched your search

+4 -2
View File
@@ -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&amp;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&amp;included_tags=white|black</code> 表示 (girl OR boy) AND (white OR black)。<br/>
示例:<a href="{examples["tag_loli"]}">{examples["tag_loli"]}</a>
</td>
</tr>
+38 -9
View File
@@ -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:
+583
View File
@@ -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
+352
View File
@@ -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
+10
View File
@@ -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
+10 -1
View File
@@ -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),
)
+55 -27
View File
@@ -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
View File
@@ -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)
+53 -1
View File
@@ -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())
+2
View File
@@ -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: "补全管理" },