Author SHA1 Message Date
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
4 changed files with 23 additions and 33 deletions
+8 -1
View File
@@ -15,7 +15,14 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY src ./src
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
EXPOSE 8000
+1 -19
View File
@@ -21,10 +21,8 @@ globalThis.window = {
}
// 捕获每次 fetch 的入参供断言
let lastUrl = ''
let lastInit: RequestInit | undefined
globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) => {
lastUrl = String(url)
globalThis.fetch = async (_url: string, init?: RequestInit) => {
lastInit = init
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } })
}
@@ -65,19 +63,3 @@ test('DELETE: method=DELETE, 无 body, 无 Content-Type', async () => {
assert.equal(lastInit?.body, 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')
})
+5 -13
View File
@@ -17,30 +17,22 @@ export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
const API_BASE = '/api/v1'
const DEFAULT_TIMEOUT = 30_000
/** 健康检查端点挂在根路径(app.py 不在 /api/v1 下,与 compose healthcheck 一致);
* 加前缀会 404,导致管理后台健康状态永远显示「系统异常」 */
const ROOT_ONLY_PREFIXES = ['/health']
/** 所有 API 路径统一走 /api/v1,避免浏览器直接请求 /matches 被 nginx 当 SPA 回退 */
function build_url(path: string): string {
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('/')) return `${API_BASE}${path}`
return `${API_BASE}/${path}`
}
export class ApiError extends Error {
status: number
data?: unknown
constructor(message: string, status: number, data?: unknown) {
constructor(
message: string,
public status: number,
public data?: unknown,
) {
super(message)
this.name = 'ApiError'
// 显式赋值而非构造函数参数属性(public x):strip-types 不支持后者,
// 会让 node --experimental-strip-types 跑 lib/http.test.ts 直接失败
this.status = status
this.data = data
}
}
+9
View File
@@ -112,6 +112,15 @@ def setup_logging(level: str = "INFO", log_file: str = "") -> None:
file_handler.addFilter(_SQLNoiseFilter())
root.addHandler(file_handler)
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:
logging.getLogger(__name__).warning(
"启用文件日志失败(%s),仅保留 stdout/内存日志", log_file, exc_info=True,