diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 3fbe7f1..8f15887 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -993,6 +993,16 @@ - 备选:仅文档要求手跑 e2e;未采用。 - 影响:`task itest` 变长;需本机 Go/Node/Chromium。 +### 复审修复 W-01 + +- 日期:2026-09-30 +- 原条款:TASKS W1 请求封装;审查 #51;PRD F01 名称可选。 +- 实际做法:`requestAdmin` 对 401(排除 login/password)清空会话并跳转登录页,提示一次「登录已过期」;`run()` 不再对 `ApiError` 重复 toast;网络错误「无法连接服务器」、429「请稍后再试」。概览/系统/端/群/投递/令牌加载失败显示 `LoadFailed` 与重试。开通名称改为可选;改密用 n-form rules 按字符数校验,后端 `login.go` 同步 `utf8.RuneCountInString`;清除对话密码加确认。 +- 未改:`RegistrationView.vue` / `registration.go`(U-01 归属;本波指令禁止改注册页)。生成新安全码确认留给身份线。 +- 原因:会话过期后页面不跳转且错误弹两次;加载失败空白;帮助文字误称 nst_。 +- 备选方案:改密 401 也自动登出;未采用,与契约「旧密码错误 401」冲突。 +- 影响:改密失败不会踢当前会话;注册页错误提示仍走原逻辑。 + ### S1.1 传输层可注入假实现(Go / JS) - 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。 diff --git a/internal/admin/login.go b/internal/admin/login.go index 972a732..6f8140f 100644 --- a/internal/admin/login.go +++ b/internal/admin/login.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "net/http" + "unicode/utf8" "git.asio.asia/nixevol/NixMsg/internal/auth" "git.asio.asia/nixevol/NixMsg/internal/httpx" @@ -127,7 +128,7 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { writeDecodeError(w, err) return } - if len(req.NewPassword) < minPasswordLen { + if utf8.RuneCountInString(req.NewPassword) < minPasswordLen { h.audit(actorString(p), "password_change", "", "bad_request", ip) httpx.WriteError(w, http.StatusBadRequest, "bad_request", "新密码至少 12 位") return diff --git a/web/src/api/admin-mock.ts b/web/src/api/admin-mock.ts index 53a1f60..378006d 100644 --- a/web/src/api/admin-mock.ts +++ b/web/src/api/admin-mock.ts @@ -22,9 +22,7 @@ async function run(fn: () => Promise, silent = false): Promise { try { return await fn(); } catch (e) { - if (!silent && e instanceof ApiError) { - message.error(e.message); - } else if (!silent && e instanceof Error) { + if (!silent && e instanceof Error && !(e instanceof ApiError)) { message.error(e.message); } throw e; diff --git a/web/src/api/admin.ts b/web/src/api/admin.ts index 7944f69..99b1e20 100644 --- a/web/src/api/admin.ts +++ b/web/src/api/admin.ts @@ -33,9 +33,7 @@ async function run(fn: () => Promise, silent = false): Promise { try { return await fn(); } catch (e) { - if (!silent && e instanceof ApiError) { - message.error(e.message); - } else if (!silent && e instanceof Error) { + if (!silent && e instanceof Error && !(e instanceof ApiError)) { message.error(e.message); } throw e; diff --git a/web/src/api/http.spec.ts b/web/src/api/http.spec.ts new file mode 100644 index 0000000..afe7b5a --- /dev/null +++ b/web/src/api/http.spec.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError, requestAdmin, setUnauthorizedHandler } from "./http"; + +vi.mock("@/utils/notify", () => ({ + message: { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + }, +})); + +import { message } from "@/utils/notify"; + +describe("requestAdmin", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + setUnauthorizedHandler(null); + vi.mocked(message.error).mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + setUnauthorizedHandler(null); + }); + + it("401 跳转登录页且只提示一次", async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ ok: false, error: { code: "unauthorized", message: "未登录" } }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ); + const handler = vi.fn(); + setUnauthorizedHandler(handler); + + await expect(requestAdmin("/api/admin/overview")).rejects.toBeInstanceOf(ApiError); + await expect(requestAdmin("/api/admin/endpoints")).rejects.toBeInstanceOf(ApiError); + + expect(handler).toHaveBeenCalled(); + expect(vi.mocked(message.error).mock.calls.filter((c) => c[0] === "登录已过期")).toHaveLength(1); + }); + + it("改密 401 不自动登出", async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ ok: false, error: { code: "unauthorized", message: "旧密码错误" } }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ); + const handler = vi.fn(); + setUnauthorizedHandler(handler); + await expect( + requestAdmin("/api/admin/password", { method: "POST", body: { old_password: "x", new_password: "yyyyyyyyyyyy" } }), + ).rejects.toMatchObject({ message: "旧密码错误" }); + expect(handler).not.toHaveBeenCalled(); + }); + + it("网络错误显示中文", async () => { + vi.mocked(fetch).mockRejectedValue(new TypeError("Failed to fetch")); + await expect(requestAdmin("/api/admin/me")).rejects.toMatchObject({ message: "无法连接服务器" }); + expect(message.error).toHaveBeenCalledWith("无法连接服务器"); + }); + + it("429 提示稍后再试", async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ ok: false, error: { code: "rate_limited", message: "locked" } }), { + status: 429, + headers: { "Content-Type": "application/json" }, + }), + ); + await expect(requestAdmin("/api/admin/login", { method: "POST", body: {} })).rejects.toMatchObject({ + message: "请求过于频繁,请稍后再试", + }); + expect(message.error).toHaveBeenCalledWith("请求过于频繁,请稍后再试"); + }); +}); diff --git a/web/src/api/http.ts b/web/src/api/http.ts index b92c1a4..6db2679 100644 --- a/web/src/api/http.ts +++ b/web/src/api/http.ts @@ -27,9 +27,52 @@ export interface RequestOptions { contentType?: string | null; } +const noAutoLogout = new Set(["/api/admin/login", "/api/admin/password"]); + +type UnauthorizedHandler = (redirectPath: string) => void; +let unauthorizedHandler: UnauthorizedHandler | null = null; +let expiredNotified = false; + +export function setUnauthorizedHandler(handler: UnauthorizedHandler | null) { + unauthorizedHandler = handler; +} + +function requestPath(path: string): string { + const q = path.indexOf("?"); + return q >= 0 ? path.slice(0, q) : path; +} + +function currentRedirect(): string { + if (typeof window === "undefined") { + return "/"; + } + return `${window.location.pathname}${window.location.search}` || "/"; +} + +function notifyExpiredOnce() { + if (expiredNotified) { + return; + } + expiredNotified = true; + message.error("登录已过期"); + window.setTimeout(() => { + expiredNotified = false; + }, 2000); +} + +function handleUnauthorized(path: string, silent: boolean) { + if (noAutoLogout.has(requestPath(path))) { + return; + } + if (!silent) { + notifyExpiredOnce(); + } + unauthorizedHandler?.(currentRedirect()); +} + /** - * 管理接口请求封装:自动带 credentials 与 X-Nixmsg-Request: 1,错误直接 message 显示。 - * W4 接真实后端时页面不必改,只需让 admin 模块走本函数。 + * 管理接口请求封装:自动带 credentials 与 X-Nixmsg-Request: 1。 + * 错误只在这里提示一次;401(登录/改密除外)清空会话并跳转登录页。 */ export async function requestAdmin(path: string, opts: RequestOptions = {}): Promise { const method = opts.method ?? (opts.body != null || opts.rawBody != null ? "POST" : "GET"); @@ -46,19 +89,32 @@ export async function requestAdmin(path: string, opts: RequestOptions = {}): headers["Content-Type"] = opts.contentType; } - const res = await fetch(path, { - method, - credentials: "include", - headers, - body: body ?? undefined, - }); + let res: Response; + try { + res = await fetch(path, { + method, + credentials: "include", + headers, + body: body ?? undefined, + }); + } catch { + const err = new ApiError("network", "无法连接服务器", 0); + if (!opts.silent) { + message.error(err.message); + } + throw err; + } + + if (res.status === 401) { + handleUnauthorized(path, Boolean(opts.silent)); + } let envelope: ApiEnvelope; try { envelope = (await res.json()) as ApiEnvelope; } catch { const err = new ApiError("internal", `响应不是 JSON(HTTP ${res.status})`, res.status); - if (!opts.silent) { + if (!opts.silent && res.status !== 401) { message.error(err.message); } throw err; @@ -66,7 +122,7 @@ export async function requestAdmin(path: string, opts: RequestOptions = {}): if (typeof envelope !== "object" || envelope === null) { const err = new ApiError("internal", "响应格式无效", res.status); - if (!opts.silent) { + if (!opts.silent && res.status !== 401) { message.error(err.message); } throw err; @@ -77,8 +133,14 @@ export async function requestAdmin(path: string, opts: RequestOptions = {}): } const errBody: ApiErrorBody = envelope.error ?? { code: "internal", message: "未知错误" }; - const err = new ApiError(errBody.code, errBody.message, res.status, "data" in envelope ? envelope.data : undefined); - if (!opts.silent) { + let display = errBody.message; + if (res.status === 429) { + display = "请求过于频繁,请稍后再试"; + } else if (res.status === 401 && !noAutoLogout.has(requestPath(path))) { + display = "登录已过期"; + } + const err = new ApiError(errBody.code, display, res.status, "data" in envelope ? envelope.data : undefined); + if (!opts.silent && res.status !== 401) { message.error(err.message); } throw err; diff --git a/web/src/components/LoadFailed.vue b/web/src/components/LoadFailed.vue new file mode 100644 index 0000000..fd8b4e6 --- /dev/null +++ b/web/src/components/LoadFailed.vue @@ -0,0 +1,19 @@ + + + diff --git a/web/src/main.ts b/web/src/main.ts index 8c01e94..93fea8a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -2,8 +2,22 @@ import { createApp } from "vue"; import { createPinia } from "pinia"; import App from "./App.vue"; import { router } from "./router"; +import { setUnauthorizedHandler } from "./api/http"; +import { useAuthStore } from "./stores/auth"; const app = createApp(App); -app.use(createPinia()); +const pinia = createPinia(); +app.use(pinia); app.use(router); + +setUnauthorizedHandler((redirect) => { + const auth = useAuthStore(); + auth.clearSession(); + if (router.currentRoute.value.meta.public) { + return; + } + const target = redirect.startsWith("/login") ? "/overview" : redirect || "/overview"; + void router.replace({ name: "login", query: { redirect: target } }); +}); + app.mount("#app"); diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 7d35d41..82c08f7 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -32,5 +32,9 @@ export const useAuthStore = defineStore("auth", () => { } } - return { username, ready, isLoggedIn, hydrate, login, logout }; + function clearSession() { + username.value = null; + } + + return { username, ready, isLoggedIn, hydrate, login, logout, clearSession }; }); diff --git a/web/src/views/EndpointsView.vue b/web/src/views/EndpointsView.vue index da4394e..ca93cc0 100644 --- a/web/src/views/EndpointsView.vue +++ b/web/src/views/EndpointsView.vue @@ -2,6 +2,7 @@ import { computed, h, onMounted, reactive, ref } from "vue"; import type { DataTableColumns, DataTableRowKey } from "naive-ui"; import { + NAlert, NButton, NDataTable, NForm, @@ -21,6 +22,7 @@ import type { UploadCustomRequestOptions } from "naive-ui"; import PageHeader from "@/components/PageHeader.vue"; import HelpTip from "@/components/HelpTip.vue"; import SecretOnceAlert from "@/components/SecretOnceAlert.vue"; +import LoadFailed from "@/components/LoadFailed.vue"; import { batchEndpoints, createEndpoint, @@ -41,6 +43,7 @@ import { message } from "@/utils/notify"; const dialog = useDialog(); const loading = ref(false); +const loadError = ref(""); const rows = ref([]); const total = ref(0); const checkedKeys = ref([]); @@ -77,6 +80,7 @@ const createForm = reactive({ default_delay_seconds: 0, }); const createLoading = ref(false); +const createError = ref(""); const editOpen = ref(false); const editId = ref(""); @@ -94,6 +98,7 @@ const talkPassword = ref(""); async function load() { loading.value = true; + loadError.value = ""; try { const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : ""; const res = await listEndpoints({ @@ -105,6 +110,8 @@ async function load() { }); rows.value = res.items; total.value = res.total; + } catch (e) { + loadError.value = e instanceof Error ? e.message : "加载失败"; } finally { loading.value = false; } @@ -187,14 +194,12 @@ function openCreate() { talk_password: "", default_delay_seconds: 0, }); + createError.value = ""; createOpen.value = true; } async function submitCreate() { - if (!createForm.name.trim()) { - message.error("名称不能为空"); - return; - } + createError.value = ""; createLoading.value = true; try { const res = await createEndpoint({ @@ -212,6 +217,8 @@ async function submitCreate() { filename: `${res.id}-password.txt`, }; await load(); + } catch (e) { + createError.value = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "开通失败"; } finally { createLoading.value = false; } @@ -252,10 +259,23 @@ function openTalk(r: Endpoint) { } async function submitTalk() { - await setTalkPassword(talkId.value, talkPassword.value); - talkOpen.value = false; - message.success(talkPassword.value ? "已设置对话密码" : "已清除对话密码"); - await load(); + const save = async () => { + await setTalkPassword(talkId.value, talkPassword.value); + talkOpen.value = false; + message.success(talkPassword.value ? "已设置对话密码" : "已清除对话密码"); + await load(); + }; + if (!talkPassword.value) { + dialog.warning({ + title: "清除对话密码", + content: "留空保存将清除该端的对话密码,确定继续?", + positiveText: "清除", + negativeText: "取消", + onPositiveClick: () => save(), + }); + return; + } + await save(); } async function onResetPwd(r: Endpoint) { @@ -360,6 +380,8 @@ function onPageSizeChange(s: number) {
+ +
- + + - + @@ -464,7 +488,7 @@ function onPageSizeChange(s: number) { - +
+ +
diff --git a/web/src/views/MessagesView.vue b/web/src/views/MessagesView.vue index aca82d4..91f0a22 100644 --- a/web/src/views/MessagesView.vue +++ b/web/src/views/MessagesView.vue @@ -14,10 +14,12 @@ import { } from "naive-ui"; import PageHeader from "@/components/PageHeader.vue"; import HelpTip from "@/components/HelpTip.vue"; +import LoadFailed from "@/components/LoadFailed.vue"; import { getMessage, listMessages, type MessageDelivery, type MessageDetail, type MessageSummary } from "@/api/admin"; import { formatLocalMs } from "@/utils/time"; const loading = ref(false); +const loadError = ref(""); const rows = ref([]); const total = ref(0); const page = ref(1); @@ -41,6 +43,7 @@ const stateOptions = [ async function load() { loading.value = true; + loadError.value = ""; try { const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : ""; const res = await listMessages({ @@ -53,6 +56,8 @@ async function load() { }); rows.value = res.items; total.value = res.total; + } catch (e) { + loadError.value = e instanceof Error ? e.message : "加载失败"; } finally { loading.value = false; } @@ -128,6 +133,8 @@ async function openDetail(seq: number) {
+ +
diff --git a/web/src/views/OverviewView.spec.ts b/web/src/views/OverviewView.spec.ts new file mode 100644 index 0000000..922b83b --- /dev/null +++ b/web/src/views/OverviewView.spec.ts @@ -0,0 +1,74 @@ +import { config, mount, flushPromises } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + NConfigProvider, + NDialogProvider, + NMessageProvider, + dateZhCN, + zhCN, +} from "naive-ui"; +import { defineComponent, h } from "vue"; +import OverviewView from "./OverviewView.vue"; +import * as admin from "@/api/admin"; +import { mockApi } from "@/api/mock"; + +vi.mock("@/api/admin", async () => import("@/api/admin-mock")); + +config.global.stubs = { teleport: true }; + +function wrap(Comp: object) { + return defineComponent({ + setup() { + return () => + h(NConfigProvider, { locale: zhCN, dateLocale: dateZhCN, size: "small" }, { + default: () => + h(NMessageProvider, null, { + default: () => + h(NDialogProvider, null, { + default: () => h(Comp), + }), + }), + }); + }, + }); +} + +describe("OverviewView 加载失败", () => { + beforeEach(() => { + setActivePinia(createPinia()); + mockApi._setSession(); + vi.restoreAllMocks(); + }); + + it("失败时显示重试,点后重新加载", async () => { + const spy = vi.spyOn(admin, "fetchOverview"); + spy.mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce({ + version: "0.1.0", + endpoints_total: 3, + endpoints_online: 1, + endpoints_disabled: 1, + endpoints_self: 1, + groups_total: 1, + messages_pending: 0, + messages_scheduled: 0, + uptime_ms: 1000, + }); + + const w = mount(wrap(OverviewView), { + global: { plugins: [createPinia()] }, + attachTo: document.body, + }); + await flushPromises(); + + expect(w.text()).toContain("加载失败"); + expect(w.find('[data-testid="load-retry"]').exists()).toBe(true); + + await w.find('[data-testid="load-retry"]').trigger("click"); + await flushPromises(); + + expect(w.text()).toContain("0.1.0"); + expect(w.text()).not.toContain("加载失败"); + w.unmount(); + }); +}); diff --git a/web/src/views/OverviewView.vue b/web/src/views/OverviewView.vue index 4773d3c..482fc4c 100644 --- a/web/src/views/OverviewView.vue +++ b/web/src/views/OverviewView.vue @@ -3,18 +3,28 @@ import { onMounted, ref } from "vue"; import { NDescriptions, NDescriptionsItem, NSpin } from "naive-ui"; import PageHeader from "@/components/PageHeader.vue"; import HelpTip from "@/components/HelpTip.vue"; +import LoadFailed from "@/components/LoadFailed.vue"; import { fetchOverview, type Overview } from "@/api/admin"; import { formatLocalMs } from "@/utils/time"; const loading = ref(true); +const loadError = ref(""); const data = ref(null); -onMounted(async () => { +async function load() { + loading.value = true; + loadError.value = ""; try { data.value = await fetchOverview(); + } catch (e) { + loadError.value = e instanceof Error ? e.message : "加载失败"; } finally { loading.value = false; } +} + +onMounted(() => { + void load(); }); @@ -23,7 +33,8 @@ onMounted(async () => {
- + + {{ data.version }}