Skip to content

Commit 0bdab92

Browse files
committed
fix: 修复额度解析与前端产物定位两处真实缺陷
1. CodeBuddy 个人版额度解析完全错误(本地用真实凭证验证时暴露) 真实响应是三层嵌套 data.Response.Data.Accounts,我只读了顶层 data.Accounts —— 结果有效凭证被误判为「未探测到额度」。 另外上游把 *Precise 字段返回成字符串("500"),原解析只认 int/float, 整批额度会被算成 0。 → 按已知路径依次尝试提取;_number 接受数字字符串。 → 用脱敏后的真实响应固化为 fixture(含字符串 Precise),契约测试锁死。 2. 前端产物路径依赖当前工作目录 从仓库根以外的目录启动服务时找不到 web/dist,界面只给一句 英文「frontend build not found」,用户不知道该怎么办。 → 路径锚定项目根(源码运行)/ /app/web/dist(容器)/ cwd 兜底; 缺失时返回中文说明页:给出可执行的构建命令,并明确 API 仍可用。 SPA 测试改为注入临时产物目录,不再在仓库里建删 web/dist。 后端 582 测试 / 覆盖 100%。真实凭证验证:额度 9071.29/9305、健康度 97%、 周期结束 2026-10-11,凭证状态恢复「可用」。
1 parent bc0da98 commit 0bdab92

5 files changed

Lines changed: 384 additions & 110 deletions

File tree

‎src/main.py‎

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -460,19 +460,23 @@ async def authorize(request: Request):
460460

461461
@app.get("/{path:path}", include_in_schema=False)
462462
async def spa(path: str):
463-
"""生产模式服务 web/dist;开发模式由 Vite 代理,无需此路由。"""
464-
from fastapi.responses import FileResponse, PlainTextResponse
463+
"""生产模式服务前端产物;开发模式由 Vite 代理,不经过此路由。
465464
466-
dist = Path("web/dist")
467-
if not dist.is_dir():
468-
return PlainTextResponse("frontend build not found; run pnpm build in web/",
469-
status_code=404)
465+
路径锚定到项目根(而不是当前工作目录),否则从其他目录启动服务时
466+
会找不到前端产物。找不到时给出可执行的下一步,而不是一句
467+
「frontend build not found」。
468+
"""
469+
from fastapi.responses import FileResponse, HTMLResponse
470+
471+
dist = _frontend_dist()
472+
if dist is None:
473+
return HTMLResponse(_FRONTEND_MISSING_HTML, status_code=503)
470474
candidate = (dist / path).resolve()
471475
if path and candidate.is_file() and dist.resolve() in candidate.parents:
472476
return FileResponse(candidate)
473477
index = dist / "index.html"
474478
if not index.is_file():
475-
return PlainTextResponse("index.html missing", status_code=404)
479+
return HTMLResponse(_FRONTEND_MISSING_HTML, status_code=503)
476480
return FileResponse(index)
477481

478482
return app
@@ -483,6 +487,38 @@ def resolve_public_callback_url(settings: Settings) -> str:
483487
return settings.public_base_url.rstrip("/") + "/authorize"
484488

485489

490+
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
491+
_CONTAINER_DIST = Path("/app/web/dist")
492+
493+
_FRONTEND_MISSING_HTML = """<!doctype html>
494+
<html lang="zh-CN"><head><meta charset="utf-8"><title>coding2api</title>
495+
<style>body{font-family:ui-sans-serif,system-ui,sans-serif;max-width:44rem;margin:4rem auto;
496+
padding:0 1.5rem;line-height:1.7;color:#1f2937}
497+
code{background:#f3f4f6;padding:.15rem .4rem;border-radius:.25rem;font-size:.9em}
498+
pre{background:#f3f4f6;padding:1rem;border-radius:.5rem;overflow-x:auto}
499+
h1{font-size:1.25rem}</style></head>
500+
<body><h1>管理台前端尚未构建</h1>
501+
<p>后端已经在运行,但找不到前端产物,因此无法显示管理界面。</p>
502+
<p>在项目根目录执行:</p>
503+
<pre>cd web &amp;&amp; pnpm install &amp;&amp; pnpm build</pre>
504+
<p>构建完成后刷新本页即可。API 端点(<code>/v1/*</code>、<code>/api/*</code>)
505+
不受影响,现在就可以用。</p>
506+
</body></html>"""
507+
508+
509+
def _frontend_dist() -> Path | None:
510+
"""定位前端产物目录;不存在时返回 None。"""
511+
candidates = (
512+
_PROJECT_ROOT / "web" / "dist", # 源码运行
513+
_CONTAINER_DIST, # 容器内固定路径
514+
Path.cwd() / "web" / "dist", # 兜底:从仓库根启动
515+
)
516+
for candidate in candidates:
517+
if (candidate / "index.html").is_file():
518+
return candidate
519+
return None
520+
521+
486522
def describe_probe_failure(error: Exception) -> str:
487523
"""把探测异常翻译成用户能据以行动的原因。
488524

‎src/provider/codebuddy/client.py‎

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,9 @@ async def _fetch_personal_quota(self, credential: CodeBuddyCredential) -> Quota:
114114
"PackageEndTimeRangeBegin": time.strftime("%Y-%m-%d %H:%M:%S", now),
115115
"PackageEndTimeRangeEnd": QUOTA_RANGE_END,
116116
}
117-
data = await self._post_json(f"{self.endpoint}{EP_USER_RESOURCE}", payload,
117+
body = await self._post_json(f"{self.endpoint}{EP_USER_RESOURCE}", payload,
118118
credential, quota_only=True)
119-
if "Accounts" not in data:
120-
raise UpstreamProtocolViolation("quota response missing Accounts")
121-
accounts = data.get("Accounts")
122-
if accounts is None: # null = 探测成功但无个人版额度
123-
accounts = []
124-
if not isinstance(accounts, list):
125-
raise UpstreamProtocolViolation("quota Accounts is not an array")
119+
accounts = _extract_accounts(body)
126120
total = 0.0
127121
remaining = 0.0
128122
cycle_end: int | None = None
@@ -164,17 +158,63 @@ async def _post_json(self, url: str, payload: dict[str, Any],
164158
return data
165159

166160

161+
def _extract_accounts(body: dict[str, Any]) -> list[Any]:
162+
"""从上游响应里取出账户列表。
163+
164+
真实结构是三层嵌套:``data.Response.Data.Accounts``。
165+
只读 ``data.Accounts`` 会把有效凭证误判成「未探测到额度」。
166+
上游历史上出现过不同层级,因此按已知路径依次尝试,最后才失败。
167+
"""
168+
data = body.get("data")
169+
if not isinstance(data, dict):
170+
raise UpstreamProtocolViolation("quota response missing data")
171+
for path in (
172+
("Response", "Data", "Accounts"), # 实测结构
173+
("Response", "Accounts"),
174+
("Data", "Accounts"),
175+
("Accounts",), # 兼容更早的扁平结构
176+
):
177+
node: Any = data
178+
for key in path:
179+
node = node.get(key) if isinstance(node, dict) else None
180+
if isinstance(node, list):
181+
return node
182+
if node is None and path == ("Response", "Data", "Accounts"):
183+
# 该路径存在但值为 null → 探测成功但无个人版额度
184+
holder: Any = data
185+
for key in path[:-1]:
186+
holder = holder.get(key) if isinstance(holder, dict) else None
187+
if isinstance(holder, dict) and path[-1] in holder:
188+
return []
189+
raise UpstreamProtocolViolation("quota response missing Accounts")
190+
191+
167192
def _cycle_capacity(account: dict[str, Any], field: str) -> float:
168-
"""优先 *Precise 字段(AGENTS.md:额度以 Precise 为准)。"""
193+
"""优先 *Precise 字段(AGENTS.md:额度以 Precise 为准)。
194+
195+
上游把 Precise 值返回成**字符串**("500"),因此必须能解析数字字符串,
196+
否则整批额度都会被算成 0。
197+
"""
169198
precise = account.get(f"{field}Precise")
170199
value = precise if precise is not None else account.get(field)
171200
return _number(value) or 0.0
172201

173202

174203
def _number(value: Any) -> float | None:
175-
if isinstance(value, bool) or not isinstance(value, (int, float)):
204+
"""接受 int/float 以及上游返回的数字字符串(Precise 字段是字符串)。"""
205+
if isinstance(value, bool):
176206
return None
177-
return float(value)
207+
if isinstance(value, (int, float)):
208+
return float(value)
209+
if isinstance(value, str):
210+
text = value.strip()
211+
if not text:
212+
return None
213+
try:
214+
return float(text)
215+
except ValueError:
216+
return None
217+
return None
178218

179219

180220
def _cycle_end_epoch(value: Any) -> int | None:
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
{
2+
"code": 0,
3+
"msg": "OK",
4+
"requestId": "<redacted>",
5+
"data": {
6+
"Response": {
7+
"Data": {
8+
"TotalCount": 36,
9+
"TotalDosage": 9070,
10+
"Accounts": [
11+
{
12+
"AccountId": 3336507,
13+
"Uin": "<redacted>",
14+
"AppId": "<redacted>",
15+
"DealName": "<redacted>",
16+
"CapacityType": 4,
17+
"CapacityUnit": "credits",
18+
"CreateTime": 1773153389000,
19+
"CycleStartTime": "2026-09-01 00:00:00",
20+
"CycleEndTime": "2026-09-30 23:59:59",
21+
"DeductionStartTime": 1773153388000,
22+
"DeductionEndTime": 2033562987000,
23+
"FeeType": 1,
24+
"Region": "ap-others",
25+
"Zone": "ap-others-4",
26+
"OriginUnit": "credits",
27+
"PackageCode": "<redacted>",
28+
"PackageName": "CodeBuddy个人体验版",
29+
"PackageType": "1",
30+
"PkgSourceType": 0,
31+
"ProductCode": "p_tcaca",
32+
"ProductName": "腾讯云代码助手",
33+
"SubProductCode": "sp_tcaca_codebuddy_ide",
34+
"SubProductName": "腾讯云代码助手 (IDE)",
35+
"CapacityRemain": 500,
36+
"CapacityUsed": 0,
37+
"CapacitySize": 500,
38+
"CycleCapacityRemain": 500,
39+
"CycleCapacitySize": 500,
40+
"RemainCycles": 0,
41+
"TotalCycles": 1,
42+
"ResourceCycleId": 0,
43+
"ResourceId": "<redacted>",
44+
"ResourceType": "2",
45+
"Status": 0,
46+
"Threshold": 0,
47+
"SupportAutoRenew": 0,
48+
"SupportManualRenew": 0,
49+
"AutoRenewFlag": 0,
50+
"ExpiredTime": "",
51+
"DeductionProperties": [],
52+
"AutoRenewTimeUnit": "",
53+
"AutoRenewTimeSpan": 0,
54+
"RegionId": 47,
55+
"ZoneId": 470004,
56+
"CapacitySizePrecise": "500",
57+
"CapacityRemainPrecise": "500",
58+
"CapacityUsedPrecise": "0",
59+
"CycleCapacitySizePrecise": "500",
60+
"CycleCapacityRemainPrecise": "500",
61+
"CycleCapacityUsedPrecise": "0"
62+
},
63+
{
64+
"AccountId": 6890091,
65+
"Uin": "<redacted>",
66+
"AppId": "<redacted>",
67+
"DealName": "<redacted>",
68+
"CapacityType": 1,
69+
"CapacityUnit": "credits",
70+
"CreateTime": 1774883836000,
71+
"CycleStartTime": "2026-03-30 23:17:16",
72+
"CycleEndTime": "2027-03-30 23:17:15",
73+
"DeductionStartTime": 1774883836000,
74+
"DeductionEndTime": 1806419835000,
75+
"FeeType": 1,
76+
"Region": "ap-others",
77+
"Zone": "ap-others-4",
78+
"OriginUnit": "credits",
79+
"PackageCode": "<redacted>",
80+
"PackageName": "CodeBuddy个人版国内运营裂变包",
81+
"PackageType": "1",
82+
"PkgSourceType": 0,
83+
"ProductCode": "p_tcaca",
84+
"ProductName": "腾讯云代码助手",
85+
"SubProductCode": "sp_tcaca_codebuddyide_bonus_pack",
86+
"SubProductName": "腾讯云代码助手 (IDE) - 赠送包",
87+
"CapacityRemain": 4767,
88+
"CapacityUsed": 232,
89+
"CapacitySize": 5000,
90+
"CycleCapacityRemain": 4767,
91+
"CycleCapacitySize": 5000,
92+
"CycleCapacityUsed": 232,
93+
"RemainCycles": 0,
94+
"TotalCycles": 1,
95+
"ResourceCycleId": 0,
96+
"ResourceId": "<redacted>",
97+
"ResourceType": "2",
98+
"Status": 0,
99+
"Threshold": 0,
100+
"SupportAutoRenew": 0,
101+
"SupportManualRenew": 0,
102+
"AutoRenewFlag": 0,
103+
"ExpiredTime": "",
104+
"DeductionProperties": [],
105+
"AutoRenewTimeUnit": "",
106+
"AutoRenewTimeSpan": 0,
107+
"RegionId": 47,
108+
"ZoneId": 470004,
109+
"CapacitySizePrecise": "5000",
110+
"CapacityRemainPrecise": "4767.50000158",
111+
"CapacityUsedPrecise": "232.49999842",
112+
"CycleCapacitySizePrecise": "5000",
113+
"CycleCapacityRemainPrecise": "4767.50000158",
114+
"CycleCapacityUsedPrecise": "232.49999842"
115+
}
116+
]
117+
},
118+
"RequestId": "<redacted>"
119+
}
120+
}
121+
}

0 commit comments

Comments
 (0)