From 05110087d1596c37c01d5fc372eaf9a54103f5da Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 13:54:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(M08-D):=20=E8=A1=A5=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E4=B8=8E=E5=8F=AF=E6=92=A4=E9=94=80=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/App.vue | 64 +++--- admin/src/api.ts | 98 +++++++- admin/src/components/AdminLoginPanel.vue | 36 +++ admin/src/components/MembersStaffPanel.vue | 22 +- admin/src/styles.css | 181 ++++++++++++++- admin/src/types.ts | 24 ++ backend/package.json | 2 +- backend/src/app.ts | 5 + backend/src/auth/admin-auth-repository.ts | 216 ++++++++++++++++++ backend/src/auth/password.ts | 46 ++++ backend/src/db/migration-runner.ts | 8 +- backend/src/routes/admin-auth.ts | 142 ++++++++++++ backend/src/server.ts | 9 + backend/tests/admin-auth.test.mjs | 87 +++++++ backend/tests/migration-contract.test.mjs | 9 + backend/tests/migration-runner.test.mjs | 6 +- .../tests/mysql-migration-roundtrip.test.mjs | 71 +++++- ...26081004_m08d_admin_password_auth.down.sql | 6 + ...2026081004_m08d_admin_password_auth.up.sql | 28 +++ ...081004_m08d_admin_password_auth.verify.sql | 25 ++ scripts/check-admin-m08-d.mjs | 22 ++ 21 files changed, 1059 insertions(+), 48 deletions(-) create mode 100644 admin/src/components/AdminLoginPanel.vue create mode 100644 backend/src/auth/admin-auth-repository.ts create mode 100644 backend/src/auth/password.ts create mode 100644 backend/src/routes/admin-auth.ts create mode 100644 backend/tests/admin-auth.test.mjs create mode 100644 database/migrations/2026081004_m08d_admin_password_auth.down.sql create mode 100644 database/migrations/2026081004_m08d_admin_password_auth.up.sql create mode 100644 database/migrations/2026081004_m08d_admin_password_auth.verify.sql diff --git a/admin/src/App.vue b/admin/src/App.vue index 23ac712..eb3c12f 100644 --- a/admin/src/App.vue +++ b/admin/src/App.vue @@ -1,6 +1,7 @@ diff --git a/admin/src/api.ts b/admin/src/api.ts index ce133c2..97441f5 100644 --- a/admin/src/api.ts +++ b/admin/src/api.ts @@ -1,6 +1,9 @@ import type { Advertisement, AdvertisementInput, + AdminIdentity, + AdminSessionPayload, + AdminAccess, AuditLog, CleaningSettlement, CleaningSettlementDetail, @@ -66,6 +69,34 @@ export interface ApiSession { token: string; } +const ACCESS_TOKEN_KEY = 'qipai.admin.token'; +const REFRESH_TOKEN_KEY = 'qipai.admin.refreshToken'; +let refreshInFlight: Promise | null = null; + +export async function adminPasswordLogin(input: { + tenantCode: string; loginName: string; password: string; +}) { + return publicRequest('/auth/login', { + method: 'POST', body: JSON.stringify(input) + }); +} + +export function getAdminMe(session: ApiSession) { + return request<{ user: AdminIdentity; access: AdminAccess }>(session, '/auth/me'); +} + +export function logoutAdmin(session: ApiSession) { + return request<{ revoked: boolean }>(session, '/auth/logout', { method: 'POST' }); +} + +export function setAdminCredential(session: ApiSession, userId: string, input: { + loginName: string; password: string; +}) { + return request<{ userId: string; configured: boolean }>(session, + `/auth/credentials/${encodeURIComponent(userId)}`, + { method: 'PUT', body: JSON.stringify(input) }); +} + export function listManagedStores(session: ApiSession) { return request(session, '/stores'); } @@ -317,14 +348,22 @@ async function request( if (!session.token.trim()) { throw new ApiError('AUTH_TOKEN_REQUIRED', '需要先填入后台访问令牌'); } - const headers = new Headers(options.headers); - headers.set('authorization', `Bearer ${session.token.trim()}`); - if (options.body && !headers.has('content-type')) { - headers.set('content-type', 'application/json'); + let response = await authorizedFetch(path, options, session.token.trim()); + if (response.status === 401 && localStorage.getItem(REFRESH_TOKEN_KEY)) { + try { + const refreshed = await refreshAccessToken(); + response = await authorizedFetch(path, options, refreshed.accessToken); + } catch { + clearStoredSession(); + window.dispatchEvent(new Event('qipai:admin-unauthorized')); + } } - const response = await fetch(`${API_BASE}${path}`, { ...options, headers }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.code !== 0) { + if (response.status === 401) { + clearStoredSession(); + window.dispatchEvent(new Event('qipai:admin-unauthorized')); + } throw new ApiError( String(payload.code || `HTTP_${response.status}`), String(payload.message || '请求失败'), @@ -334,6 +373,55 @@ async function request( return payload.data as T; } +async function publicRequest(path: string, options: RequestInit): Promise { + const headers = new Headers(options.headers); + headers.set('content-type', 'application/json'); + headers.set('x-request-id', requestId()); + const response = await fetch(`${API_BASE}${path}`, { ...options, headers }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.code !== 0) throw new ApiError( + String(payload.code || `HTTP_${response.status}`), + String(payload.message || '请求失败'), String(payload.traceId || '') + ); + return payload.data as T; +} + +async function authorizedFetch(path: string, options: RequestInit, token: string) { + const headers = new Headers(options.headers); + headers.set('authorization', `Bearer ${token}`); + headers.set('x-request-id', requestId()); + if (options.body && !headers.has('content-type')) headers.set('content-type', 'application/json'); + return fetch(`${API_BASE}${path}`, { ...options, headers }); +} + +async function refreshAccessToken() { + if (!refreshInFlight) { + const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || ''; + refreshInFlight = publicRequest('/auth/refresh', { + method: 'POST', body: JSON.stringify({ refreshToken }) + }).then((payload) => { + storeAdminSession(payload); + window.dispatchEvent(new CustomEvent('qipai:admin-refreshed', { detail: payload })); + return payload; + }).finally(() => { refreshInFlight = null; }); + } + return refreshInFlight; +} + +export function storeAdminSession(payload: AdminSessionPayload) { + localStorage.setItem(ACCESS_TOKEN_KEY, payload.accessToken); + localStorage.setItem(REFRESH_TOKEN_KEY, payload.refreshToken); +} + +export function clearStoredSession() { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); +} + +function requestId() { + return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + export function listCleaningTasks( session: ApiSession, input: { page: number; pageSize: number; status?: TaskStatus; storeId?: string; cleanerUserId?: string } diff --git a/admin/src/components/AdminLoginPanel.vue b/admin/src/components/AdminLoginPanel.vue new file mode 100644 index 0000000..0a709fb --- /dev/null +++ b/admin/src/components/AdminLoginPanel.vue @@ -0,0 +1,36 @@ + + + diff --git a/admin/src/components/MembersStaffPanel.vue b/admin/src/components/MembersStaffPanel.vue index 0d25507..1a7889a 100644 --- a/admin/src/components/MembersStaffPanel.vue +++ b/admin/src/components/MembersStaffPanel.vue @@ -47,7 +47,7 @@ - +
@@ -72,14 +72,26 @@ {{ item.label }} + + + + + + + + + 至少 12 位,同时包含字母、数字和特殊字符。 + + +