Author SHA1 Message Date
WorkBuddy 0742eef52e fix(quality): 质量检查引用已删除的 MatchStats.id 必然 500 + 检查无日志
生产实测: POST /admin/data-quality/run 100% 失败,且日志无任何记录。

根因链:
1. P0-02 把 MatchStats 主键改为 match_id(无 id 属性),但质量检查
   查询仍写 MatchStats.id.is_(None) → AttributeError → 500
2. handler 无 logger 调用;未捕获异常走 uvicorn.error(默认
   propagate=False),不经过 root handler → 内存日志页/文件日志
   都看不到 traceback,排障无据可查

修复:
- MatchStats.id → MatchStats.match_id(与 P0-02 模型对齐)
- 成功路径记 info 日志(检查名=通过/未通过(计数))
- setup_logging 打开 uvicorn/uvicorn.error 的 propagate,
  未捕获异常 traceback 进入内存缓冲与滚动文件
- 回归测试 3 例(mock session):修复前红(3 failed),修复后绿;
  全量 329 passed 8 skipped 零回归
2026-09-22 18:03:20 +08:00
WorkBuddy f00bf71e8f Merge pull request 'design: 全站设计打磨(语义色 token/弹窗动效与无障碍/报头收敛/交互小项)' (#19) from design-polish into main 2026-09-22 17:46:06 +08:00
WorkBuddy 246b06379d Merge pull request 'fix(admin): 日志页默认定位到最新而非最早' (#18) from fix-logs-order into main 2026-09-22 17:45:50 +08:00
WorkBuddy 3fc3e91bd3 Merge pull request 'fix(admin): /health 被误加 /api/v1 前缀,健康状态永远「系统异常」' (#17) from fix-healthcheck-url into main 2026-09-22 17:45:48 +08:00
WorkBuddy 007a276fb7 Merge pull request 'fix(docker): 预创建 /app/logs 属主,修复文件日志 PermissionError' (#16) from fix-log-volume-permission into main 2026-09-22 17:45:47 +08:00
WorkBuddy 77ecb078a3 fix(admin): 日志页改为保持倒序,打开自动定位到顶部(最新)
按用户预期调整方案:列表保持最新在最上面(后端倒序),打开页面/
自动刷新时 scrollTop=0 定位顶部;向下滚动回看历史时暂停定位,
拉回顶部即恢复。替代上一版的时间正序+贴底方案。
2026-09-22 15:39:19 +08:00
WorkBuddy 51fbc1828e fix(admin): 日志页默认定位到最新而非最早
渲染顺序(最新在前)与滚动逻辑假设(正序+贴底=最新)相互矛盾:
自动刷新(默认开启)每 10s 强制贴底,而底部恰好是最早的日志,
用户打开页面被钉在最旧的一条上。

改为渲染时间正序(旧→新),与贴底滚动/上滚暂停跟随的 tail -f
行为一致:打开页面即定位最新。
2026-09-22 15:35:59 +08:00
WorkBuddy 1422afd6f6 fix(admin): /health 请求被误加 /api/v1 前缀,健康状态永远「系统异常」
build_url 对所有路径强制加 /api/v1,但后端 /health 与 /health/ready
挂在根路径(app.py,与 compose healthcheck 一致)→ 前端请求 /api/v1/health
404 → fetchHealth 吞错返回 status=unknown → 右上角/监控页永远「系统异常」。

- build_url 对 /health 与 /health/* 豁免前缀
- http.test.ts 补 URL 豁免用例(该测试此前因 ApiError 参数属性不被
  strip-types 支持而完全无法运行,顺手改为显式赋值使基建可用)
2026-09-22 14:22:51 +08:00
WorkBuddy 7c4a244ce9 fix(docker): 预创建 /app/logs 并赋属主,修复卷挂载点 root 权限拒绝
生产实测: applogs 命名卷挂到 /app/logs 时挂载点由 Docker 以 root:root
创建,而非 root 进程(profeto)写入 app.log 时 PermissionError(Errno 13),
best-effort 降级生效但文件日志实际未启用。

- Dockerfile: 镜像内预创建 /app/logs 并 chown(chown -R 已有,覆盖新目录);
  命名卷为空且首次挂载时 Docker 会复制镜像目录属主,重建镜像即可修复
- log_buffer: PermissionError 单独捕获,warning 给出可操作的排查方向
  (镜像预建目录/chown/重建空卷),其余异常仍保持原通用降级
2026-09-22 14:14:53 +08:00
7 changed files with 140 additions and 15 deletions
+8 -1
View File
@@ -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
+8 -7
View File
@@ -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])
+19 -1
View File
@@ -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')
})
+13 -5
View File
@@ -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
} }
} }
+9 -1
View File
@@ -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]}
+17
View File
@@ -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,
+66
View File
@@ -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)