Skip to content

Commit 857733e

Browse files
committed
fix(officeace): 昵称取 principal_urn 里的用户名;轮询失败透出上游原因
- `client-permission-validate` 没有独立用户名字段,用户名只在 `principal_urn`(`iam::<account>:user:<name>`)末尾。之前没解析, 凭证昵称退化成一串 32 位账号 ID,用户认不出哪条是自己的。 - 登录对话框轮询失败时统一显示「登录轮询失败,请重试」,把上游可行动 的原因(如「这个账号还没开通 officeAce 服务」)吞掉了,用户不知道该 去开通。改为透出 ApiError 的 message。
1 parent a657206 commit 857733e

6 files changed

Lines changed: 88 additions & 5 deletions

File tree

‎src/provider/officeace/events.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,12 +600,29 @@ def parse_model_info(payload: Any) -> dict[str, str]:
600600
return {
601601
"account_id": str(data.get("account_id") or ""),
602602
"principal_id": str(data.get("principal_id") or ""),
603+
"user_name": _user_name_of(data),
603604
"app_key": str(auth_info.get("model_app_key") or ""),
604605
"app_secret": str(auth_info.get("model_app_secret") or ""),
605606
"base_url": base,
606607
}
607608

608609

610+
def _user_name_of(data: dict[str, Any]) -> str:
611+
"""从 `principal_urn` 末尾取用户名:`iam::<account>:user:<name>`。
612+
613+
该接口**没有**独立的用户名字段(实测 2026-10-21),用户名只出现在 URN
614+
里。取不到就留空——调用方会退到账号 ID 当昵称,不能让登录因此失败。
615+
"""
616+
explicit = str(data.get("user_name") or data.get("userName") or "").strip()
617+
if explicit:
618+
return explicit
619+
urn = str(data.get("principal_urn") or "")
620+
marker = ":user:"
621+
if marker not in urn:
622+
return ""
623+
return urn.split(marker, 1)[1].strip()
624+
625+
609626
def build_authorize_url(*, auth_base: str, client_id: str, state: str,
610627
code_challenge: str, redirect_uri: str) -> str:
611628
"""拼华为云授权地址(参数集与 OfficeAce 客户端逐项对齐,勿增删)。

‎src/provider/officeace/oauth.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,9 @@ async def _finish(self, code: str,
372372
credential.model_base_url = info["base_url"]
373373
credential.account_id = info["account_id"] or credential.account_id
374374
credential.principal_id = info["principal_id"] or credential.principal_id
375+
# 用户名只出现在 `principal_urn` 里(接口无独立字段):补上后昵称才不是
376+
# 一串 32 位账号 ID,用户才认得出「哪条凭证是自己的」。
377+
credential.user_name = info["user_name"] or credential.user_name
375378
# 昵称只用于展示:优先上游给的、其次用户名、最后账号 ID(都没有就留空,
376379
# 前端按凭证 ID 兜底)。用 `or` 链而非 if:三者都缺是合法情况,
377380
# STS 响应本身并不带 nickname 字段。

‎tests/provider/officeace/test_events.py‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ def test_parse_model_info_reads_subscription_block():
468468
}},
469469
}
470470
assert events.parse_model_info(payload) == {
471-
"account_id": "acct", "principal_id": "prin",
471+
"account_id": "acct", "principal_id": "prin", "user_name": "",
472472
"app_key": "K", "app_secret": "S", "base_url": "https://gw.test/v2",
473473
}
474474

@@ -484,8 +484,8 @@ def test_parse_model_info_unprovisioned_account():
484484
"""未开通:`subscription: null`(实测三账号全如此)→ 各字段空。"""
485485
info = events.parse_model_info({"account_id": "a", "subscription": None,
486486
"model_info": {}})
487-
assert info == {"account_id": "a", "principal_id": "", "app_key": "",
488-
"app_secret": "", "base_url": ""}
487+
assert info == {"account_id": "a", "principal_id": "", "user_name": "",
488+
"app_key": "", "app_secret": "", "base_url": ""}
489489

490490

491491
def test_parse_model_info_tolerates_garbage():
@@ -495,6 +495,29 @@ def test_parse_model_info_tolerates_garbage():
495495
{"subscription": {"model_info": {"model_auth_info": "x"}}})["base_url"] == ""
496496

497497

498+
def test_parse_model_info_reads_user_name_from_principal_urn():
499+
"""用户名只在 `principal_urn` 末尾,接口没有独立字段(2026-10-21 实测)。"""
500+
info = events.parse_model_info(
501+
{"principal_urn": "iam::acct:user:robbsluo"})
502+
assert info["user_name"] == "robbsluo"
503+
504+
505+
def test_parse_model_info_prefers_explicit_user_name():
506+
for key in ("user_name", "userName"):
507+
info = events.parse_model_info(
508+
{key: " carol ", "principal_urn": "iam::acct:user:ignored"})
509+
assert info["user_name"] == "carol"
510+
511+
512+
def test_parse_model_info_urn_without_user_marker_is_blank():
513+
"""URM 形态变了也不能炸:认不出就留空,昵称退到账号 ID。"""
514+
assert events.parse_model_info({"principal_urn": "iam::acct"})["user_name"] == ""
515+
assert events.parse_model_info(
516+
{"principal_urn": 123})["user_name"] == ""
517+
assert events.parse_model_info(
518+
{"user_name": " "})["user_name"] == ""
519+
520+
498521
# ------------------------------------------------------------------ 授权 URL
499522

500523
def test_build_authorize_url_wraps_login_page():

‎tests/provider/officeace/test_oauth.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,17 @@ async def test_poll_nickname_falls_back_to_user_name():
381381
assert result is not None and result.nickname == "carol"
382382

383383

384+
async def test_poll_nickname_from_principal_urn():
385+
"""令牌里没名字时用开通校验的 `principal_urn`:否则昵称是一串账号 ID。"""
386+
oauth, state = await _started(_flow(permission=_permission_response(
387+
principal_urn="iam::acct:user:dave")))
388+
result = await oauth.poll(state, "alice")
389+
assert result is not None
390+
assert result.nickname == "dave"
391+
assert OfficeAceCredential.from_dict(
392+
result.credential_data).user_name == "dave"
393+
394+
384395
async def test_poll_concurrent_consume_raises():
385396
"""两路轮询同时拿到码时,后完成的那路不能再落一条重复凭证。"""
386397
class LosingStore(AuthStateStore):

‎web/src/components/AddCredentialDialog.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,13 @@ export function AddCredentialDialog({
173173
onNotice("登录成功,凭证已保存。");
174174
onImported("登录成功,凭证已保存。");
175175
}
176-
} catch {
176+
} catch (caught) {
177177
stopPolling(provider);
178-
onError("登录轮询失败,请重试。");
178+
// 轮询失败多半是上游给的可行动原因(如「这个账号还没开通 officeAce」),
179+
// 兜底文案会把它抹掉,用户只会看到「失败」而不知道该去做什么。
180+
onError(caught instanceof Error && caught.message
181+
? caught.message
182+
: "登录轮询失败,请重试。");
179183
}
180184
}, interval);
181185
loginTimersRef.current[provider] = timer;

‎web/src/components/__tests__/AddCredentialDialog.test.tsx‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,31 @@ describe("AddCredentialDialog", () => {
8686
vi.useRealTimers();
8787
});
8888

89+
it("poll 失败时透出上游可行动原因,而不是兜底文案", async () => {
90+
vi.useFakeTimers({ shouldAdvanceTime: true });
91+
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
92+
const url = String(input);
93+
if (url.includes("/upstream/start")) {
94+
return jsonResponse({ flow: "poll", state: "s", auth_url: "https://a", interval: 1 });
95+
}
96+
if (url.includes("/upstream/poll")) {
97+
return jsonResponse({ error: {
98+
code: "upstream_error",
99+
message: "这个账号还没开通 officeAce 服务。请先在 OfficeAce 客户端里登录并兑换邀请码完成开通,再回来重新登录。",
100+
} }, 400);
101+
}
102+
return jsonResponse({ credentials: [], viewer: "root", is_admin: true });
103+
}));
104+
vi.stubGlobal("open", () => null);
105+
const props = renderDialog();
106+
await userEvent.click(screen.getByTestId("start-login-officeace"));
107+
await vi.advanceTimersByTimeAsync(2000);
108+
109+
expect(props.onError).toHaveBeenCalledWith(
110+
expect.stringContaining("还没开通 officeAce 服务"));
111+
vi.useRealTimers();
112+
});
113+
89114
it("拒绝非 http(s) 授权地址,不对弹窗赋值", async () => {
90115
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
91116
const url = String(input);

0 commit comments

Comments
 (0)