Skip to content

Commit b331d8f

Browse files
committed
fix(deploy): HOST/PORT 在容器里生效 + compose 透传全部配置项
两处静默失效的配置陷阱: 1. Dockerfile CMD 硬编码 --host 0.0.0.0 --port 8000,config.py 里的 HOST/PORT 只在本地 `python -m src.main` 生效。容器里改 PORT 只改端口 映射、改 HOST 完全无效(文档化配置项却不管用)。 → CMD 改走 src.main:run;实测 PORT=18231 时确实监听在 18231 2. compose 没有 env_file,.env 只用于 ${VAR} 插值、不注入容器。此前只透传 10 个变量,ALLOWED_HOSTS / PACER_* / REFRESH_SKEW_HOURS / DUMP_REQUEST_BODIES / CODEBUDDY_ALLOWED_ENDPOINTS / CODEBUDDY_CHAT_MIN_INTERVAL / HOST / PORT / USERS_FILE / DATA_DIR 在 .env 里设了全部无效,且无任何报错。 → 全部补齐(含注释说明为什么必须显式列出) 新增两个静态断言把这两类问题挡在 CI:compose 必须透传 config.py 的每个 字段;CMD 不得硬编码 --host/--port。README 指明 config.py 是权威清单。 前端:统计明细的失败列此前直接显示机器码(rate_limit 等), 现按 TECHNICAL §6.5 同一原则翻成中文,未知值兜底「失败(原值)」。
1 parent a8186da commit b331d8f

9 files changed

Lines changed: 104 additions & 6 deletions

File tree

‎Dockerfile‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,6 @@ EXPOSE 8000
5151
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
5252
CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health')"
5353

54-
CMD ["python", "-m", "uvicorn", "src.main:build_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
54+
# CMD 走 src.main:run(而非硬编码 --host/--port):HOST/PORT 是文档化配置项
55+
# (config.py + README),硬编码会让它们只对本地启动生效、在容器里静默失效。
56+
CMD ["python", "-m", "src.main"]

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,9 @@ curl http://127.0.0.1:8000/v1/user/balance -H "Authorization: Bearer sk-你的ke
102102

103103
## 配置
104104

105-
常用项如下,其余高级项(监听地址、上游端点白名单、节流、预刷新窗口、诊断转储等)见 `docker-compose.yml` 与 `src/config.py`。
105+
常用项如下;完整的可配置项见 `src/config.py`(权威),且**每一个都已透传到 `docker-compose.yml`**——`.env` 里写这些变量即可生效(compose 的 `.env` 只做插值,未透传的变量不会进容器)。
106+
107+
> 容器里改监听地址用 `HOST`/`PORT`(`PORT` 同时决定宿主机映射端口),入口读 `config.py`,不硬编码。
106108
107109
| 变量 | 默认 | 说明 |
108110
|---|---|---|

‎TECHNICAL.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,8 @@ fixture 断言两个方向:**解析正确**(样本 → 期望 Event)与**
360360
- **手写 SQL 而非 ORM**:8 张表规模下 ORM 收益为负
361361
- **polling OAuth 不转回调**(Q17=C):上游协议决定;TRAE 回调走主端口 + PUBLIC_BASE_URL
362362
- **v1 无 Anthropic**(Q8=A):Event 层已预留,v1.1 只加 `compat/anthropic/` 适配器
363+
- **两套数据源共存(已知不一致)**:`overview` / `by_provider` 读 `usage_events`(即时,
364+
仅覆盖 90 天明细),`timeline` / `model-timeline` 读 `usage_hourly`(≤5 分钟滞后,永久)。
365+
时间范围 ≤90 天时两者一致(汇总由同一批明细算出);选「全部」时总览会小于图表,
366+
因为超过 90 天的明细已被清理、只剩小时汇总。修法已列入待办(把总览也切到小时表),
367+
但会引入 ≤5 分钟延迟,待定。

‎docker-compose.yml‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,29 @@ services:
2121
# 会话粘性 TTL(秒):同一对话多轮请求固定用同一凭证;≤0 关闭
2222
CONVERSATION_STICKY_SECONDS: ${CONVERSATION_STICKY_SECONDS:-3600}
2323
LOG_LEVEL: ${LOG_LEVEL:-INFO}
24+
# ---- 高级项:默认值已合适,需要时在 .env 里设即可生效 ----
25+
# 注意:compose 的 .env 只用于 ${VAR} 插值,只有在这里透传过的变量
26+
# 才会真正进容器;漏透传会让 .env 里的设置静默失效(无任何报错)。
27+
# Host 白名单(防 DNS rebinding);空 = 本地回环 + PUBLIC_BASE_URL 主机
28+
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-}
29+
# 上游端点白名单(逗号分隔);改 CODEBUDDY_API_ENDPOINT 时必须同时含它
30+
CODEBUDDY_ALLOWED_ENDPOINTS: ${CODEBUDDY_ALLOWED_ENDPOINTS:-https://copilot.tencent.com,https://www.codebuddy.ai}
31+
# CodeBuddy 聊天最小间隔(秒):腾讯频率风控,0 关闭
32+
CODEBUDDY_CHAT_MIN_INTERVAL: ${CODEBUDDY_CHAT_MIN_INTERVAL:-5}
33+
# token 预刷新窗口(小时)
34+
REFRESH_SKEW_HOURS: ${REFRESH_SKEW_HOURS:-24}
35+
# 全局节流器随机区间(秒)
36+
PACER_MIN_SECONDS: ${PACER_MIN_SECONDS:-5}
37+
PACER_MAX_SECONDS: ${PACER_MAX_SECONDS:-20}
38+
# 诊断:把 /v1 原始请求体落盘到 data/dumps/(含对话内容,仅排查用)
39+
DUMP_REQUEST_BODIES: ${DUMP_REQUEST_BODIES:-false}
40+
# 服务监听地址(容器内必须 0.0.0.0 才能被映射访问)
41+
HOST: ${HOST:-0.0.0.0}
42+
PORT: ${PORT:-8000}
43+
# 用户文件路径(容器内固定挂载点,一般无需改)
44+
USERS_FILE: ${USERS_FILE:-/app/secrets/users.txt}
45+
# SQLite 与运行数据目录(容器内固定挂载点,一般无需改)
46+
DATA_DIR: ${DATA_DIR:-/app/data}
2447
ports:
2548
- "${PORT:-8000}:8000"
2649
volumes:

‎tests/test_deployment_assets.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,33 @@ def test_compose_publishes_same_port_as_dockerfile():
107107
assert f":{dockerfile_port.group(1)}" in COMPOSE.read_text(encoding="utf-8")
108108

109109

110+
def test_compose_forwards_every_settings_field():
111+
"""compose 必须透传 config.py 的每个可配置字段。
112+
113+
compose 的 .env 只用于 ${VAR} 插值,**不会**注入容器:没写进
114+
environment 的变量在 .env 里设了也无效,而且完全无报错(静默失效)。
115+
所以这里把两边对一遍,漏一个就抦住。
116+
"""
117+
config_text = (ROOT / "src" / "config.py").read_text(encoding="utf-8")
118+
fields = set(re.findall(r"^ ([a-z_]+):[^=\n]*=", config_text, re.MULTILINE))
119+
# pydantic-settings 默认大小写不敏感,惯例上 env 全大写
120+
expected = {name.upper() for name in fields}
121+
compose_text = COMPOSE.read_text(encoding="utf-8")
122+
forwarded = set(re.findall(r"^\s+([A-Z_]+):", compose_text, re.MULTILINE))
123+
missing = expected - forwarded
124+
assert not missing, f"compose 未透传(.env 里设了也不会生效): {sorted(missing)}"
125+
126+
127+
def test_compose_forwards_port_used_by_entrypoint():
128+
"""HOST/PORT 必须既透传又在入口生效(CMD 不得硬编码地址)。"""
129+
compose_text = COMPOSE.read_text(encoding="utf-8")
130+
assert "${PORT" in compose_text
131+
cmd = re.search(r"^CMD (.+)$", DOCKERFILE.read_text(encoding="utf-8"), re.MULTILINE)
132+
assert cmd is not None
133+
# 硬编码 --host/--port 会让 config.py 里的 HOST/PORT 在容器里静默失效
134+
assert "--host" not in cmd.group(1) and "--port" not in cmd.group(1)
135+
136+
110137
def test_ci_workflow_paths_match_repository():
111138
"""CI 里用到的路径必须存在,否则 workflow 必然失败。"""
112139
workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")

‎web/src/api/__tests__/client.test.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
formatTime,
1414
healthView,
1515
probeFailureLabel,
16+
usageErrorLabel,
1617
quotaSemantics,
1718
} from "../display";
1819
import { makeCredential } from "../../pages/__tests__/helpers";
@@ -211,6 +212,15 @@ describe("display helpers", () => {
211212
expect(probeFailureLabel("something_new" as never)).toBe("未知错误");
212213
});
213214

215+
it("明细失败类型翻成中文,未知值不进主提示但保留原文", () => {
216+
expect(usageErrorLabel("rate_limit")).toBe("额度耗尽");
217+
expect(usageErrorLabel("client_disconnect")).toBe("客户端中断");
218+
expect(usageErrorLabel(null)).toBe("失败");
219+
expect(usageErrorLabel(undefined)).toBe("失败");
220+
// 后端新增枚举时前端不能显示空白:兜底文案带原值便于对照排查
221+
expect(usageErrorLabel("brand_new_reason")).toBe("失败(brand_new_reason)");
222+
});
223+
214224
it("时间与数字格式化处理空值", () => {
215225
expect(formatTime(null)).toBe("—");
216226
expect(formatTime(1_700_000_000)).not.toBe("—");

‎web/src/api/display.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,35 @@ export function formatChartValue(value: number, metric: string): string {
143143
}
144144

145145

146+
/** 统计明细的失败类型(线值,来自后端 CONTROLLED_ERROR_TYPES)。
147+
* 注意与 ProbeFailureReason 不是同一套:那是探测失败,这是请求失败。 */
148+
export type UsageErrorType =
149+
| "client_disconnect"
150+
| "credential_unavailable"
151+
| "invalid_request"
152+
| "no_healthy_credential"
153+
| "rate_limit"
154+
| "upstream_error"
155+
| "upstream_protocol";
156+
157+
/** 明细失败类型的中文说明(TECHNICAL §6.5:界面只展示稳定枚举的翻译)。 */
158+
export const USAGE_ERROR_LABEL: Record<UsageErrorType, string> = {
159+
client_disconnect: "客户端中断",
160+
credential_unavailable: "凭证失效",
161+
invalid_request: "请求无效",
162+
no_healthy_credential: "无可用凭证",
163+
rate_limit: "额度耗尽",
164+
upstream_error: "渠道错误",
165+
upstream_protocol: "渠道响应异常",
166+
};
167+
168+
/** 未知取值不得原样透传(TECHNICAL §6.5),兜底为「失败」。 */
169+
export function usageErrorLabel(errorType: string | null | undefined): string {
170+
if (!errorType) return "失败";
171+
return USAGE_ERROR_LABEL[errorType as UsageErrorType] ?? `失败(${errorType})`;
172+
}
173+
174+
146175
export const PROBE_FAILURE_LABEL: Record<ProbeFailureReason, string> = {
147176
credential_rejected: "凭证被渠道拒绝,需要重新登录该账号",
148177
rate_limited: "渠道限流,稍后重试",

‎web/src/pages/StatsPage.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
useStatsOverview,
99
useStatsTimeline,
1010
} from "../api/hooks";
11-
import { formatCompact, formatLatency, formatNumber, formatTime } from "../api/display";
11+
import { formatCompact, formatLatency, formatNumber, formatTime, usageErrorLabel } from "../api/display";
1212
import { Notice } from "../ui";
1313
import { ModelTrendChart } from "../components/ModelTrendChart";
1414
import { PageHeader } from "../components/PageHeader";
@@ -51,12 +51,12 @@ const PROVIDER_LABEL: Record<Provider, string> = { codebuddy: "CodeBuddy", trae:
5151
/** 明细分页的可选每页条数。 */
5252
const PAGE_SIZES = [10, 20, 50, 100];
5353

54-
/** 明细状态列:成功固定文案;失败展示受控错误类型(脱敏,不含原始错误体)。 */
54+
/** 明细状态列:成功固定文案;失败展示受控错误类型的中文说明(脱敏,不含原始错误体)。 */
5555
function statusCell(row: UsageEventRow) {
5656
if (row.ok) {
5757
return <span className="text-ok">成功</span>;
5858
}
59-
return <span className="text-destructive">{row.error_type ?? "失败"}</span>;
59+
return <span className="text-destructive">{usageErrorLabel(row.error_type)}</span>;
6060
}
6161

6262
export function StatsPage() {

‎web/src/pages/__tests__/StatsPage.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ describe("StatsPage", () => {
234234
expect(within(table).getByText("主账号")).toBeInTheDocument();
235235
expect(within(table).getByText("glm-5.2")).toBeInTheDocument();
236236
expect(within(table).getByText("成功")).toBeInTheDocument();
237-
expect(within(table).getByText("rate_limit")).toBeInTheDocument();
237+
expect(within(table).getByText("额度耗尽")).toBeInTheDocument();
238238
expect(within(table).getByText("8.2 s")).toBeInTheDocument();
239239
expect(within(table).getByText("2.4 s")).toBeInTheDocument();
240240
expect(within(table).getByText("90")).toBeInTheDocument();

0 commit comments

Comments
 (0)