Compare commits
10
Commits
design-polish
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38e1c6c31f | ||
|
|
0742eef52e | ||
|
|
f00bf71e8f | ||
|
|
246b06379d | ||
|
|
3fc3e91bd3 | ||
|
|
007a276fb7 | ||
|
|
77ecb078a3 | ||
|
|
51fbc1828e | ||
|
|
1422afd6f6 | ||
|
|
7c4a244ce9 |
+8
-1
@@ -15,7 +15,14 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
# 将工作目录所有权移交给非 root 用户
|
# 日志目录预创建: compose 运行时把 applogs 命名卷挂到 /app/logs,
|
||||||
|
# 卷挂载点默认由 Docker 以 root:root 创建 —— 镜像内不预建的话,
|
||||||
|
# 非 root 进程写日志会 PermissionError(Errno 13)。
|
||||||
|
# 命名卷为空且首次挂载时,Docker 会复制镜像内该目录的内容与属主,
|
||||||
|
# 因此在这里 mkdir + chown 即可让卷目录归 profeto 所有。
|
||||||
|
RUN mkdir -p /app/logs
|
||||||
|
|
||||||
|
# 将工作目录所有权移交给非 root 用户(含上面的日志目录)
|
||||||
RUN chown -R profeto:profeto /app
|
RUN chown -R profeto:profeto /app
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -53,20 +53,21 @@ export default function LogsPage() {
|
|||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const userScrolledUp = useRef(false)
|
const userScrolledDown = useRef(false)
|
||||||
|
|
||||||
// 检测用户是否向上滚动过
|
// 检测用户是否向下滚动回看历史(旧日志在下方)
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50
|
const atTop = el.scrollTop < 50
|
||||||
userScrolledUp.current = !atBottom
|
userScrolledDown.current = !atTop
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载后自动滚动到底部(仅当用户未向上滚动时)
|
// 列表最新在最上面(后端倒序返回);打开页面/自动刷新时定位到顶部=最新。
|
||||||
|
// 用户向下滚动回看历史时暂停定位,拉回顶部即恢复。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoRefresh && !userScrolledUp.current && scrollRef.current) {
|
if (autoRefresh && !userScrolledDown.current && scrollRef.current) {
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
scrollRef.current.scrollTop = 0
|
||||||
}
|
}
|
||||||
}, [entries, autoRefresh])
|
}, [entries, autoRefresh])
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ globalThis.window = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 捕获每次 fetch 的入参供断言
|
// 捕获每次 fetch 的入参供断言
|
||||||
|
let lastUrl = ''
|
||||||
let lastInit: RequestInit | undefined
|
let lastInit: RequestInit | undefined
|
||||||
globalThis.fetch = async (_url: string, init?: RequestInit) => {
|
globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) => {
|
||||||
|
lastUrl = String(url)
|
||||||
lastInit = init
|
lastInit = init
|
||||||
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } })
|
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||||
}
|
}
|
||||||
@@ -63,3 +65,19 @@ test('DELETE: method=DELETE, 无 body, 无 Content-Type', async () => {
|
|||||||
assert.equal(lastInit?.body, undefined)
|
assert.equal(lastInit?.body, undefined)
|
||||||
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], undefined)
|
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 健康检查豁免: /health* 挂在根路径(后端 app.py 不在 /api/v1 下),
|
||||||
|
// 加前缀会 404 → 管理后台右上角永远「系统异常」 ──
|
||||||
|
test('build_url: /health 与 /health/ready 不加 /api/v1 前缀', async () => {
|
||||||
|
await http.get('/health')
|
||||||
|
assert.equal(lastUrl, '/health')
|
||||||
|
await http.get('/health/ready')
|
||||||
|
assert.equal(lastUrl, '/health/ready')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('build_url: 常规 API 路径仍统一加 /api/v1', async () => {
|
||||||
|
await http.get('/admin/stats')
|
||||||
|
assert.equal(lastUrl, '/api/v1/admin/stats')
|
||||||
|
await http.get('/matches')
|
||||||
|
assert.equal(lastUrl, '/api/v1/matches')
|
||||||
|
})
|
||||||
|
|||||||
@@ -17,22 +17,30 @@ export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
|
|||||||
const API_BASE = '/api/v1'
|
const API_BASE = '/api/v1'
|
||||||
const DEFAULT_TIMEOUT = 30_000
|
const DEFAULT_TIMEOUT = 30_000
|
||||||
|
|
||||||
|
/** 健康检查端点挂在根路径(app.py 不在 /api/v1 下,与 compose healthcheck 一致);
|
||||||
|
* 加前缀会 404,导致管理后台健康状态永远显示「系统异常」 */
|
||||||
|
const ROOT_ONLY_PREFIXES = ['/health']
|
||||||
|
|
||||||
/** 所有 API 路径统一走 /api/v1,避免浏览器直接请求 /matches 被 nginx 当 SPA 回退 */
|
/** 所有 API 路径统一走 /api/v1,避免浏览器直接请求 /matches 被 nginx 当 SPA 回退 */
|
||||||
function build_url(path: string): string {
|
function build_url(path: string): string {
|
||||||
if (path.startsWith('http')) return path
|
if (path.startsWith('http')) return path
|
||||||
|
if (ROOT_ONLY_PREFIXES.some(p => path === p || path.startsWith(`${p}/`))) return path
|
||||||
if (path.startsWith(API_BASE)) return path
|
if (path.startsWith(API_BASE)) return path
|
||||||
if (path.startsWith('/')) return `${API_BASE}${path}`
|
if (path.startsWith('/')) return `${API_BASE}${path}`
|
||||||
return `${API_BASE}/${path}`
|
return `${API_BASE}/${path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
status: number
|
||||||
message: string,
|
data?: unknown
|
||||||
public status: number,
|
|
||||||
public data?: unknown,
|
constructor(message: string, status: number, data?: unknown) {
|
||||||
) {
|
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'ApiError'
|
this.name = 'ApiError'
|
||||||
|
// 显式赋值而非构造函数参数属性(public x):strip-types 不支持后者,
|
||||||
|
// 会让 node --experimental-strip-types 跑 lib/http.test.ts 直接失败
|
||||||
|
this.status = status
|
||||||
|
this.data = data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -234,13 +234,14 @@ async def run_data_quality_check(db: AsyncSession = Depends(get_db_read)):
|
|||||||
checks = []
|
checks = []
|
||||||
|
|
||||||
# 检查1: 已完赛但无统计的比赛
|
# 检查1: 已完赛但无统计的比赛
|
||||||
|
# 注意: MatchStats 主键是 match_id(P0-02),不是 id —— 引用 .id 会 AttributeError
|
||||||
finished_no_stats = (
|
finished_no_stats = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(Match)
|
.select_from(Match)
|
||||||
.outerjoin(MatchStats, Match.id == MatchStats.match_id)
|
.outerjoin(MatchStats, Match.id == MatchStats.match_id)
|
||||||
.where(Match.match_status == "finished")
|
.where(Match.match_status == "finished")
|
||||||
.where(MatchStats.id.is_(None))
|
.where(MatchStats.match_id.is_(None))
|
||||||
)
|
)
|
||||||
).scalar() or 0
|
).scalar() or 0
|
||||||
|
|
||||||
@@ -276,6 +277,13 @@ async def run_data_quality_check(db: AsyncSession = Depends(get_db_read)):
|
|||||||
db.add(c)
|
db.add(c)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# 成功路径留痕:检查何时跑过、各项结果如何(此前 handler 无任何日志,
|
||||||
|
# 加上未捕获异常走 uvicorn.error 不进内存缓冲,线上排障无据可查)
|
||||||
|
logger.info(
|
||||||
|
"数据质量检查完成: %s",
|
||||||
|
"; ".join(f"{c.check_name}={'通过' if c.passed else '未通过'}({c.actual_value:.0f})" for c in checks),
|
||||||
|
)
|
||||||
|
|
||||||
return {"ok": True, "checks": [{"name": c.check_name, "passed": c.passed} for c in checks]}
|
return {"ok": True, "checks": [{"name": c.check_name, "passed": c.passed} for c in checks]}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ def setup_logging(level: str = "INFO", log_file: str = "") -> None:
|
|||||||
if root.level == logging.NOTSET or root.level > logging.INFO:
|
if root.level == logging.NOTSET or root.level > logging.INFO:
|
||||||
root.setLevel(getattr(logging, level.upper(), logging.INFO))
|
root.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||||
|
|
||||||
|
# uvicorn 的 logger 默认 propagate=False:未捕获异常的 traceback 只进
|
||||||
|
# stderr,不经过 root 的任何 handler —— Admin 日志页与文件日志都看不到,
|
||||||
|
# 线上 500 排障无据可查。打开 propagate 让它们进入内存缓冲/滚动文件。
|
||||||
|
for uv_name in ("uvicorn", "uvicorn.error"):
|
||||||
|
uv_logger = logging.getLogger(uv_name)
|
||||||
|
if not uv_logger.propagate:
|
||||||
|
uv_logger.propagate = True
|
||||||
|
|
||||||
if not any(isinstance(h, MemoryLogHandler) for h in root.handlers):
|
if not any(isinstance(h, MemoryLogHandler) for h in root.handlers):
|
||||||
handler = MemoryLogHandler()
|
handler = MemoryLogHandler()
|
||||||
handler.setLevel(logging.INFO)
|
handler.setLevel(logging.INFO)
|
||||||
@@ -112,6 +120,15 @@ def setup_logging(level: str = "INFO", log_file: str = "") -> None:
|
|||||||
file_handler.addFilter(_SQLNoiseFilter())
|
file_handler.addFilter(_SQLNoiseFilter())
|
||||||
root.addHandler(file_handler)
|
root.addHandler(file_handler)
|
||||||
logging.getLogger(__name__).info("文件日志已启用: %s", log_file)
|
logging.getLogger(__name__).info("文件日志已启用: %s", log_file)
|
||||||
|
except PermissionError:
|
||||||
|
# 容器场景最常见:挂载卷目录属主是 root,进程是非 root 用户。
|
||||||
|
# 修复方向:镜像内预创建目录并 chown(Dockerfile),或重建空卷。
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"启用文件日志失败(%s):无写权限。容器部署请确认镜像已预创建该目录"
|
||||||
|
"并 chown 给运行用户(compose 卷挂载点默认 root 属主);"
|
||||||
|
"宿主机直跑请检查目录权限。本次仅保留 stdout/内存日志。",
|
||||||
|
log_file,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.getLogger(__name__).warning(
|
logging.getLogger(__name__).warning(
|
||||||
"启用文件日志失败(%s),仅保留 stdout/内存日志", log_file, exc_info=True,
|
"启用文件日志失败(%s),仅保留 stdout/内存日志", log_file, exc_info=True,
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""数据质量检查回归测试。
|
||||||
|
|
||||||
|
背景(P0-02 遗留): MatchStats 主键改为 match_id 后,质量检查查询仍引用
|
||||||
|
MatchStats.id → AttributeError → POST /admin/data-quality/run 必然 500,
|
||||||
|
前端显示「运行失败」;且 handler 无日志,未捕获异常走 uvicorn.error
|
||||||
|
(propagate=False)不进内存缓冲/文件日志,排障时无据可查。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from src.api.routes import admin_quality
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_db(scalars: list[int]) -> MagicMock:
|
||||||
|
"""按顺序返回 scalar() 计数的假 AsyncSession。"""
|
||||||
|
db = MagicMock()
|
||||||
|
results = []
|
||||||
|
for v in scalars:
|
||||||
|
r = MagicMock()
|
||||||
|
r.scalar.return_value = v
|
||||||
|
results.append(r)
|
||||||
|
db.execute = AsyncMock(side_effect=results)
|
||||||
|
db.add = MagicMock()
|
||||||
|
db.commit = AsyncMock()
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_data_quality_check_no_attribute_error():
|
||||||
|
"""检查查询不得引用 MatchStats.id(P0-02 后该属性不存在)。
|
||||||
|
|
||||||
|
修复前: run_data_quality_check 抛 AttributeError → 500。
|
||||||
|
"""
|
||||||
|
db = _fake_db([3, 1])
|
||||||
|
|
||||||
|
out = await admin_quality.run_data_quality_check(db)
|
||||||
|
|
||||||
|
assert out["ok"] is True
|
||||||
|
assert out["checks"] == [
|
||||||
|
{"name": "finished_without_stats", "passed": False},
|
||||||
|
{"name": "league_without_standings", "passed": False},
|
||||||
|
]
|
||||||
|
# 检查结果落库(2 条 DataQualityCheck)
|
||||||
|
assert db.add.call_count == 2
|
||||||
|
db.commit.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_data_quality_check_all_passed():
|
||||||
|
db = _fake_db([0, 0])
|
||||||
|
|
||||||
|
out = await admin_quality.run_data_quality_check(db)
|
||||||
|
|
||||||
|
assert out["ok"] is True
|
||||||
|
assert all(c["passed"] for c in out["checks"])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_data_quality_check_logs_summary(caplog):
|
||||||
|
"""成功路径必须留日志:否则线上无从得知检查何时跑过、结果如何。"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
db = _fake_db([0, 0])
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO, logger="src.api.routes.admin_quality"):
|
||||||
|
await admin_quality.run_data_quality_check(db)
|
||||||
|
|
||||||
|
assert any("数据质量检查" in r.message for r in caplog.records)
|
||||||
Reference in New Issue
Block a user