feat(M08-D): 补后台登录与可撤销会话

This commit is contained in:
Codex
2026-08-10 13:54:03 +08:00
parent 93d83e81b9
commit 05110087d1
21 changed files with 1059 additions and 48 deletions
+38 -26
View File
@@ -1,6 +1,7 @@
<template>
<el-config-provider>
<main class="app-shell">
<AdminLoginPanel v-if="!savedToken" @authenticated="handleAuthenticated" />
<main v-else class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark"></div>
@@ -10,11 +11,12 @@
</div>
</div>
<nav class="nav-list" aria-label="后台模块">
<button class="nav-item" :class="{ active: activeModule === 'platformApps' }" type="button" @click="activeModule = 'platformApps'"><PanelsTopLeft :size="18" /><span>小程序租户</span></button>
<button class="nav-item" :class="{ active: activeModule === 'content' }" type="button" @click="activeModule = 'content'"><Images :size="18" /><span>广告装修</span></button>
<button class="nav-item" :class="{ active: activeModule === 'franchise' }" type="button" @click="activeModule = 'franchise'"><Handshake :size="18" /><span>加盟跟进</span></button>
<button class="nav-item" :class="{ active: activeModule === 'system' }" type="button" @click="activeModule = 'system'"><ScrollText :size="18" /><span>日志配置</span></button>
<button v-if="canOpen('platformApps')" class="nav-item" :class="{ active: activeModule === 'platformApps' }" type="button" @click="activeModule = 'platformApps'"><PanelsTopLeft :size="18" /><span>小程序租户</span></button>
<button v-if="canOpen('content')" class="nav-item" :class="{ active: activeModule === 'content' }" type="button" @click="activeModule = 'content'"><Images :size="18" /><span>广告装修</span></button>
<button v-if="canOpen('franchise')" class="nav-item" :class="{ active: activeModule === 'franchise' }" type="button" @click="activeModule = 'franchise'"><Handshake :size="18" /><span>加盟跟进</span></button>
<button v-if="canOpen('system')" class="nav-item" :class="{ active: activeModule === 'system' }" type="button" @click="activeModule = 'system'"><ScrollText :size="18" /><span>日志配置</span></button>
<button
v-if="canOpen('overview')"
class="nav-item"
:class="{ active: activeModule === 'overview' }"
type="button"
@@ -24,6 +26,7 @@
<span>运营总览</span>
</button>
<button
v-if="canOpen('cleaning')"
class="nav-item"
:class="{ active: activeModule === 'cleaning' }"
type="button"
@@ -33,6 +36,7 @@
<span>保洁运营</span>
</button>
<button
v-if="canOpen('stores')"
class="nav-item"
:class="{ active: activeModule === 'stores' }"
type="button"
@@ -42,6 +46,7 @@
<span>门店房间</span>
</button>
<button
v-if="canOpen('orders')"
class="nav-item"
:class="{ active: activeModule === 'orders' }"
type="button"
@@ -51,6 +56,7 @@
<span>订单运营</span>
</button>
<button
v-if="canOpen('devices')"
class="nav-item"
:class="{ active: activeModule === 'devices' }"
type="button"
@@ -60,6 +66,7 @@
<span>设备</span>
</button>
<button
v-if="canOpen('payments')"
class="nav-item"
:class="{ active: activeModule === 'payments' }"
type="button"
@@ -69,6 +76,7 @@
<span>支付分账</span>
</button>
<button
v-if="canOpen('thirdParty')"
class="nav-item"
:class="{ active: activeModule === 'thirdParty' }"
type="button"
@@ -78,6 +86,7 @@
<span>团购平台</span>
</button>
<button
v-if="canOpen('people')"
class="nav-item"
:class="{ active: activeModule === 'people' }"
type="button"
@@ -95,18 +104,9 @@
<p class="eyebrow">{{ activeModuleMeta.stage }}</p>
<h2>{{ activeModuleMeta.title }}</h2>
</div>
<div class="token-box">
<el-input
v-model="tokenDraft"
type="password"
show-password
placeholder="后台访问令牌"
autocomplete="off"
@keyup.enter="saveToken"
/>
<el-tooltip content="保存令牌" placement="bottom">
<el-button :icon="Save" type="primary" @click="saveToken" />
</el-tooltip>
<div class="session-box">
<div class="session-user"><span>{{ currentUser?.nickname?.slice(0, 1) || '管' }}</span><div><strong>{{ currentUser?.nickname || '管理员' }}</strong><small>{{ currentAccess.roles.join(' · ') || '已登录' }}</small></div></div>
<el-tooltip content="安全退出" placement="bottom"><el-button :icon="LogOut" circle @click="handleLogout" /></el-tooltip>
</div>
</header>
@@ -322,7 +322,7 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import {
ClipboardCheck,
@@ -336,7 +336,7 @@ import {
RadioTower,
RotateCcw,
ScrollText,
Save,
LogOut,
Send,
Sparkles,
Store,
@@ -360,6 +360,7 @@ import PlatformAppsPanel from './components/PlatformAppsPanel.vue';
import ContentManagementPanel from './components/ContentManagementPanel.vue';
import FranchisePanel from './components/FranchisePanel.vue';
import LogsSystemPanel from './components/LogsSystemPanel.vue';
import AdminLoginPanel from './components/AdminLoginPanel.vue';
import {
ApiError,
assignCleaningTask,
@@ -370,20 +371,27 @@ import {
executeWechatTransfer,
generateCleaningSettlement,
getCleaningStatistics,
getAdminMe,
listCleaningSettlements,
listCleaningTasks,
listStaffUsers,
logoutAdmin,
reclaimCleaningTimeouts,
recordPayoutFailure,
rejectCleaningTask,
resetStaffSessions,
syncWechatTransfer,
clearStoredSession,
storeAdminSession,
updateStaffUser
} from './api';
import type {
CleaningSettlement,
CleaningStatistics,
CleaningTask,
AdminAccess,
AdminIdentity,
AdminSessionPayload,
ManagedUser,
PageResult,
PayoutStateFilter,
@@ -395,7 +403,8 @@ import type {
import { money } from './format';
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
const tokenDraft = ref(savedToken.value);
const currentUser = ref<AdminIdentity | null>(null);
const currentAccess = reactive<AdminAccess>({ roles: [], capabilities: [], storeIds: [], menus: [] });
const activeModule = ref<'overview' | 'platformApps' | 'content' | 'franchise' | 'system' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
const activeTab = ref('tasks');
const lastError = ref('');
@@ -462,12 +471,13 @@ const operationalQueueTotal = computed(() => (
+ draftSettlementTotal.value
));
function saveToken() {
savedToken.value = tokenDraft.value.trim();
localStorage.setItem('qipai.admin.token', savedToken.value);
ElMessage.success('已保存');
if (activeModule.value === 'cleaning' && savedToken.value) void loadCleaningWorkspace();
}
function handleAuthenticated(payload: AdminSessionPayload) { storeAdminSession(payload); savedToken.value = payload.accessToken; currentUser.value = payload.user; Object.assign(currentAccess, payload.access); selectAllowedModule(); ElMessage.success('登录成功'); }
async function handleLogout() { try { await logoutAdmin(session.value); } catch { /* local cleanup is authoritative */ } clearSession(); }
function clearSession() { clearStoredSession(); savedToken.value = ''; currentUser.value = null; Object.assign(currentAccess, { roles: [], capabilities: [], storeIds: [], menus: [] }); }
function handleRefreshed(event: Event) { const payload = (event as CustomEvent<AdminSessionPayload>).detail; savedToken.value = payload.accessToken; currentUser.value = payload.user; Object.assign(currentAccess, payload.access); selectAllowedModule(); }
async function restoreSession() { if (!savedToken.value) return; try { const result = await getAdminMe(session.value); currentUser.value = result.user; Object.assign(currentAccess, result.access); selectAllowedModule(); } catch { /* request layer refreshes or clears */ } }
function canOpen(module: string) { return currentAccess.menus.includes(module); }
function selectAllowedModule() { if (!canOpen(activeModule.value)) activeModule.value = (currentAccess.menus[0] || 'overview') as typeof activeModule.value; }
function loadCleaningWorkspace() {
return Promise.all([loadTasks(), loadSettlements(), loadStatistics(), loadCleaners()]);
@@ -821,4 +831,6 @@ async function handleResetCleanerSessions(userId: string) {
watch(activeModule, (module) => {
if (module === 'cleaning' && session.value.token) void loadCleaningWorkspace();
});
onMounted(() => { window.addEventListener('qipai:admin-refreshed', handleRefreshed); window.addEventListener('qipai:admin-unauthorized', clearSession); void restoreSession(); });
onBeforeUnmount(() => { window.removeEventListener('qipai:admin-refreshed', handleRefreshed); window.removeEventListener('qipai:admin-unauthorized', clearSession); });
</script>
+93 -5
View File
@@ -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<AdminSessionPayload> | null = null;
export async function adminPasswordLogin(input: {
tenantCode: string; loginName: string; password: string;
}) {
return publicRequest<AdminSessionPayload>('/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<ManagedStore[]>(session, '/stores');
}
@@ -317,14 +348,22 @@ async function request<T>(
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<T>(
return payload.data as T;
}
async function publicRequest<T>(path: string, options: RequestInit): Promise<T> {
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<AdminSessionPayload>('/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 }
+36
View File
@@ -0,0 +1,36 @@
<template>
<main class="login-shell">
<section class="login-story" aria-label="平台介绍">
<div class="login-brand"><span></span><strong>自助棋牌室运营平台</strong></div>
<div><p class="section-kicker">QIPAI OPERATIONS</p><h1>一处掌握门店订单设备与资金</h1><p>统一租户权限可撤销会话与全链路审计为日常运营提供可信工作台</p></div>
<ul><li><ShieldCheck :size="18" />短期访问令牌与轮换刷新令牌</li><li><Fingerprint :size="18" />scrypt 密码哈希与失败锁定</li><li><ScrollText :size="18" />关键操作按租户完整留痕</li></ul>
</section>
<section class="login-form-wrap">
<div class="login-card">
<header><p class="section-kicker">管理后台</p><h2>欢迎回来</h2><p>使用租户代码和后台账号登录</p></header>
<el-alert v-if="lastError" :title="lastError" type="error" show-icon :closable="false" />
<el-form label-position="top" @submit.prevent="login">
<el-form-item label="租户代码"><el-input v-model="form.tenantCode" size="large" autocomplete="organization" placeholder="例如 demo" @keyup.enter="login" /></el-form-item>
<el-form-item label="登录账号"><el-input v-model="form.loginName" size="large" autocomplete="username" placeholder="请输入后台账号" @keyup.enter="login" /></el-form-item>
<el-form-item label="密码"><el-input v-model="form.password" size="large" type="password" show-password autocomplete="current-password" placeholder="请输入密码" @keyup.enter="login" /></el-form-item>
<el-button type="primary" size="large" :loading="loading" :disabled="!canSubmit" @click="login">安全登录</el-button>
</el-form>
<footer><LockKeyhole :size="15" /><span>连续失败 5 次将锁定账号 15 分钟</span></footer>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { Fingerprint, LockKeyhole, ScrollText, ShieldCheck } from '@lucide/vue';
import { adminPasswordLogin, ApiError } from '../api';
import type { AdminSessionPayload } from '../types';
const emit = defineEmits<{ authenticated: [payload: AdminSessionPayload] }>();
const loading = ref(false); const lastError = ref('');
const form = reactive({ tenantCode: localStorage.getItem('qipai.admin.tenantCode') || '', loginName: '', password: '' });
const canSubmit = computed(() => form.tenantCode.trim().length >= 2 && form.loginName.trim().length >= 3 && form.password.length > 0);
async function login() { if (!canSubmit.value || loading.value) return; loading.value = true; lastError.value = ''; try { const payload = await adminPasswordLogin({ tenantCode: form.tenantCode.trim(), loginName: form.loginName.trim(), ['password']: form.password }); localStorage.setItem('qipai.admin.tenantCode', form.tenantCode.trim()); form.password = ''; emit('authenticated', payload); } catch (error) { lastError.value = error instanceof ApiError ? loginMessage(error) : error instanceof Error ? error.message : '登录失败'; } finally { loading.value = false; } }
function loginMessage(error: ApiError) { if (error.code === 'ADMIN_LOGIN_LOCKED') return '登录失败次数过多,账号已暂时锁定,请 15 分钟后重试。'; if (error.code === 'ADMIN_LOGIN_INVALID') return '租户代码、登录账号或密码不正确。'; return `${error.code}${error.traceId ? ` · 追踪号 ${error.traceId}` : ''}`; }
</script>
+19 -3
View File
@@ -47,7 +47,7 @@
<el-table-column label="门店范围" min-width="190"><template #default="{ row }">{{ storeScopeLabel(row.storeIds) }}</template></el-table-column>
<el-table-column label="微信 / 最近登录" min-width="170"><template #default="{ row }"><div class="stack"><strong>{{ row.wechatMiniappBound ? '已绑定小程序' : '未绑定小程序' }}</strong><span>{{ shortDate(row.lastLoginAt) }} · {{ row.maskedLastIp || '-' }}</span></div></template></el-table-column>
<el-table-column prop="note" label="运营备注" min-width="160" />
<el-table-column label="操作" width="250" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" :icon="Pencil" @click="openEdit(row)">编辑</el-button><el-button size="small" :type="row.status === 'ACTIVE' ? 'warning' : 'success'" @click="toggleStaff(row)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button size="small" :icon="ShieldX" @click="resetSessions(row)">会话</el-button></div></template></el-table-column>
<el-table-column label="操作" width="335" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" :icon="Pencil" @click="openEdit(row)">编辑</el-button><el-button size="small" :icon="KeyRound" @click="openCredential(row)">登录</el-button><el-button size="small" :type="row.status === 'ACTIVE' ? 'warning' : 'success'" @click="toggleStaff(row)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button size="small" :icon="ShieldX" @click="resetSessions(row)">会话</el-button></div></template></el-table-column>
</el-table>
<div class="pager"><el-pagination layout="prev, pager, next, total" :current-page="staff.page" :page-size="staff.pageSize" :total="staff.total" @current-change="changeStaffPage" /></div>
</el-tab-pane>
@@ -72,14 +72,26 @@
<el-form label-position="top" class="asset-form"><el-form-item label="姓名"><el-input v-model="staffForm.nickname" /></el-form-item><el-form-item :label="staffForm.userId ? '新手机号(留空保持原值)' : '手机号'"><el-input v-model="staffForm.phone" inputmode="tel" /></el-form-item><el-form-item label="角色" class="form-span-2"><el-checkbox-group v-model="staffForm.roles"><el-checkbox v-for="item in roleOptions" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox></el-checkbox-group></el-form-item><el-form-item label="门店授权" class="form-span-2"><el-select v-model="staffForm.storeIds" multiple filterable collapse-tags><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="运营备注" class="form-span-2"><el-input v-model="staffForm.note" type="textarea" :rows="3" /></el-form-item></el-form>
<template #footer><el-button @click="staffDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveStaff">保存</el-button></template>
</el-dialog>
<el-dialog v-model="credentialDialog" title="设置后台登录" width="min(480px, 94vw)">
<el-alert title="保存后将撤销该员工现有会话;密码不会在后台回显。" type="warning" show-icon :closable="false" />
<el-form label-position="top" class="credential-form">
<el-form-item label="员工"><el-input :model-value="credentialForm.nickname" disabled /></el-form-item>
<el-form-item label="登录账号"><el-input v-model="credentialForm.loginName" autocomplete="off" maxlength="64" placeholder="字母、数字、点、下划线或 @" /></el-form-item>
<el-form-item label="新密码"><el-input v-model="credentialForm.password" type="password" show-password autocomplete="new-password" maxlength="128" /></el-form-item>
<el-form-item label="确认新密码"><el-input v-model="credentialForm.confirmPassword" type="password" show-password autocomplete="new-password" maxlength="128" /></el-form-item>
<small class="field-help">至少 12 同时包含字母数字和特殊字符</small>
</el-form>
<template #footer><el-button @click="credentialDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveCredential">保存登录凭据</el-button></template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Eye, Pencil, RefreshCw, ShieldX, UserPlus } from '@lucide/vue';
import { ApiError, createStaffUser, getMember, listManagedStores, listMembers, listStaffUsers, resetStaffSessions, updateStaffUser, type ApiSession } from '../api';
import { Eye, KeyRound, Pencil, RefreshCw, ShieldX, UserPlus } from '@lucide/vue';
import { ApiError, createStaffUser, getMember, listManagedStores, listMembers, listStaffUsers, resetStaffSessions, setAdminCredential, updateStaffUser, type ApiSession } from '../api';
import { money, shortDate } from '../format';
import type { ManagedStore, ManagedUser, MemberCard, MemberDetail, PageResult, StaffRole, UserStatus } from '../types';
@@ -96,7 +108,9 @@ const staffFilters = reactive({ search: '', status: '' as UserStatus | '', role:
const memberDrawer = ref(false);
const memberDetail = ref<MemberDetail | null>(null);
const staffDialog = ref(false);
const credentialDialog = ref(false);
const staffForm = reactive({ userId: '', nickname: '', phone: '', note: '', roles: ['STAFF'] as StaffRole[], storeIds: [] as string[] });
const credentialForm = reactive({ userId: '', nickname: '', loginName: '', password: '', confirmPassword: '' });
const roleOptions: Array<{ value: StaffRole; label: string }> = [{ value: 'STAFF', label: '普通员工' }, { value: 'CLEANER', label: '保洁员' }, { value: 'STORE_ADMIN', label: '门店管理员' }, { value: 'TENANT_ADMIN', label: '租户管理员' }];
const memberBalance = computed(() => members.items.reduce((sum, item) => sum + item.wallet.totalBalanceCents, 0));
const memberSpend = computed(() => members.items.reduce((sum, item) => sum + item.orders.paidAmountCents, 0));
@@ -113,6 +127,8 @@ function changeStaffPage(page: number) { staff.page = page; void loadStaff(); }
async function openMember(memberId: string) { memberDrawer.value = true; memberDetail.value = null; try { memberDetail.value = await capture(() => getMember(props.session, memberId)); } catch { /* displayed */ } }
function openCreate() { Object.assign(staffForm, { userId: '', nickname: '', phone: '', note: '', roles: ['STAFF'], storeIds: [] }); staffDialog.value = true; }
function openEdit(row: ManagedUser) { Object.assign(staffForm, { userId: row.id, nickname: row.nickname, phone: '', note: row.note, roles: [...row.roles], storeIds: [...row.storeIds] }); staffDialog.value = true; }
function openCredential(row: ManagedUser) { Object.assign(credentialForm, { userId: row.id, nickname: row.nickname || `员工 #${row.id}`, loginName: '', password: '', confirmPassword: '' }); credentialDialog.value = true; }
async function saveCredential() { const valid = /^[\p{L}\p{N}._@+-]{3,64}$/u.test(credentialForm.loginName.trim()) && credentialForm.password.length >= 12 && /[A-Za-z]/.test(credentialForm.password) && /\d/.test(credentialForm.password) && /[^A-Za-z0-9]/.test(credentialForm.password); if (!valid) return ElMessage.warning('请填写有效登录账号和符合复杂度要求的密码'); if (credentialForm.password !== credentialForm.confirmPassword) return ElMessage.warning('两次输入的密码不一致'); saving.value = true; try { await capture(() => setAdminCredential(props.session, credentialForm.userId, { loginName: credentialForm.loginName.trim(), ['password']: credentialForm.password })); credentialDialog.value = false; ElMessage.success('后台登录凭据已更新,旧会话已撤销'); } catch { /* displayed */ } finally { saving.value = false; } }
async function saveStaff() { if (!staffForm.nickname.trim() || (!staffForm.userId && !/^\+?[0-9]{6,20}$/.test(staffForm.phone.trim())) || staffForm.roles.length === 0) return ElMessage.warning('请填写姓名、有效手机号并至少选择一个角色'); saving.value = true; try { if (staffForm.userId) await capture(() => updateStaffUser(props.session, staffForm.userId, { nickname: staffForm.nickname.trim(), ...(staffForm.phone.trim() ? { phone: staffForm.phone.trim() } : {}), note: staffForm.note.trim(), roles: staffForm.roles, storeIds: staffForm.storeIds })); else await capture(() => createStaffUser(props.session, { nickname: staffForm.nickname.trim(), phone: staffForm.phone.trim(), note: staffForm.note.trim(), roles: staffForm.roles, storeIds: staffForm.storeIds })); staffDialog.value = false; ElMessage.success(staffForm.userId ? '员工资料已更新' : '员工账号已创建'); await loadStaff(); } catch { /* displayed */ } finally { saving.value = false; } }
async function toggleStaff(row: ManagedUser) { const status: UserStatus = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; try { await ElMessageBox.confirm(`${status === 'DISABLED' ? '停用' : '启用'} ${row.nickname || `员工 #${row.id}`}?权限变更会撤销活动会话。`, '确认账号状态', { type: 'warning' }); await capture(() => updateStaffUser(props.session, row.id, { status })); ElMessage.success('员工状态已更新'); await loadStaff(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
async function resetSessions(row: ManagedUser) { try { await ElMessageBox.confirm(`重置 ${row.nickname || `员工 #${row.id}`} 的全部活动会话?`, '确认重置会话', { type: 'warning' }); const result = await capture(() => resetStaffSessions(props.session, row.id)); ElMessage.success(`已撤销 ${result.revokedSessions} 个会话`); await loadStaff(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
+177 -4
View File
@@ -6,6 +6,120 @@
"Segoe UI", sans-serif;
}
.login-shell {
display: grid;
grid-template-columns: minmax(360px, 1fr) minmax(420px, .85fr);
min-height: 100vh;
background: #f4f7fb;
}
.login-story {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 100vh;
padding: clamp(32px, 6vw, 84px);
overflow: hidden;
background:
radial-gradient(circle at 82% 18%, rgba(73, 123, 180, .28), transparent 32%),
linear-gradient(145deg, #101a2b, #173454 62%, #1e4a70);
color: #f8fafc;
}
.login-brand {
display: flex;
align-items: center;
gap: 12px;
}
.login-brand span {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border-radius: 11px;
background: #e9f2ff;
color: #152033;
font-weight: 900;
}
.login-story h1 {
max-width: 680px;
margin: 14px 0 18px;
font-size: clamp(36px, 5vw, 68px);
line-height: 1.08;
letter-spacing: -.04em;
}
.login-story > div > p:last-child {
max-width: 620px;
color: #c6d5e5;
font-size: 17px;
line-height: 1.8;
}
.login-story ul {
display: grid;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.login-story li {
display: flex;
align-items: center;
gap: 10px;
color: #dce8f4;
}
.login-form-wrap {
display: grid;
min-height: 100vh;
padding: 32px;
place-items: center;
}
.login-card {
width: min(100%, 440px);
padding: clamp(28px, 5vw, 48px);
border: 1px solid #dce4ee;
border-radius: 22px;
background: #fff;
box-shadow: 0 24px 70px rgba(30, 51, 78, .12);
}
.login-card header h2 {
margin: 6px 0 8px;
font-size: 30px;
}
.login-card header > p:last-child,
.login-card footer {
color: #6b788c;
}
.login-card .el-alert {
margin: 20px 0 0;
}
.login-card .el-form {
margin-top: 24px;
}
.login-card .el-button {
width: 100%;
}
.login-card footer {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
margin-top: 22px;
font-size: 12px;
}
.content-page {
display: grid;
gap: 16px;
@@ -564,11 +678,40 @@ textarea {
font-size: 24px;
}
.token-box {
.session-box,
.session-user {
display: flex;
align-items: center;
gap: 10px;
}
.session-user > span {
display: grid;
grid-template-columns: minmax(220px, 360px) 40px;
gap: 8px;
width: min(100%, 420px);
width: 38px;
height: 38px;
place-items: center;
border-radius: 50%;
background: #e8f1ff;
color: #1b416d;
font-weight: 800;
}
.session-user > div {
display: grid;
gap: 2px;
}
.session-user small {
max-width: 260px;
overflow: hidden;
color: #6b788c;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.credential-form {
margin-top: 18px;
}
.metric-grid {
@@ -2152,6 +2295,14 @@ textarea {
}
@media (max-width: 980px) {
.login-shell {
grid-template-columns: 1fr 1fr;
}
.login-story {
padding: 36px;
}
.app-shell {
grid-template-columns: 1fr;
}
@@ -2263,6 +2414,28 @@ textarea {
}
@media (max-width: 560px) {
.login-shell {
grid-template-columns: 1fr;
}
.login-story {
display: none;
}
.login-form-wrap {
min-height: 100vh;
padding: 18px;
}
.login-card {
padding: 26px 22px;
border-radius: 16px;
}
.session-user > div {
display: none;
}
.sidebar {
grid-template-columns: 1fr;
}
+24
View File
@@ -575,6 +575,30 @@ export interface SystemOverview {
latestMigration: null | { version: string; name: string; appliedAt: string };
}
export interface AdminIdentity {
id: string;
tenantId: string;
userType: string;
nickname: string;
avatarUrl: string;
roleVersion: number;
}
export interface AdminAccess {
roles: string[];
capabilities: string[];
storeIds: string[];
menus: string[];
}
export interface AdminSessionPayload {
accessToken: string;
refreshToken: string;
expiresIn: number;
user: AdminIdentity;
access: AdminAccess;
}
export interface CleaningTask {
id: string;
taskNo: string;
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs"
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+5
View File
@@ -61,6 +61,7 @@ import {
registerSystemOperationsRoutes,
type SystemOperationsRouteOptions
} from './routes/system-operations.js';
import { registerAdminAuthRoutes, type AdminAuthRouteOptions } from './routes/admin-auth.js';
export interface BuildAppOptions {
config?: AppConfig;
@@ -88,6 +89,7 @@ export interface BuildAppOptions {
businessStatistics?: BusinessStatisticsRouteOptions;
franchise?: FranchiseRouteOptions;
systemOperations?: SystemOperationsRouteOptions;
adminAuth?: AdminAuthRouteOptions;
}
declare module 'fastify' {
@@ -201,6 +203,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.systemOperations) {
await registerSystemOperationsRoutes(app, options.systemOperations);
}
if (options.adminAuth) {
await registerAdminAuthRoutes(app, options.adminAuth);
}
return app;
}
+216
View File
@@ -0,0 +1,216 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
import type { ManagementActor } from './user-management-repository.js';
import { verifyPassword } from './password.js';
import type { AuthSession, AuthUser } from './auth-repository.js';
interface CredentialRow extends RowDataPacket {
credentialId: string; tenantId: string; platformAppId: string; passwordHash: string;
credentialStatus: string; failedAttempts: number; lockedUntil: Date | null;
id: string; userType: string; status: string; roleVersion: number;
nickname: string; avatarUrl: string; phone: string;
}
interface RefreshRow extends RowDataPacket {
sessionId: string; tenantId: string; platformAppId: string; refreshTokenHash: string;
refreshExpiresAt: Date; id: string; userType: string; status: string; roleVersion: number;
nickname: string; avatarUrl: string; phone: string;
}
export class AdminAuthError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class AdminAuthRepository {
constructor(private readonly pool: MySqlPool) {}
async loginWithPassword(input: {
tenantCode: string; loginName: string; password: string; sessionId: string;
refreshTokenHash: string; expiresAt: Date; ip: string; userAgent: string; traceId: string;
}): Promise<AuthSession> {
const loginName = normalizeLoginName(input.loginName);
const [rows] = await this.pool.execute<CredentialRow[]>(
`SELECT c.id AS credentialId, c.tenant_id AS tenantId,
ta.platform_app_id AS platformAppId, c.password_hash AS passwordHash,
c.status AS credentialStatus, c.failed_attempts AS failedAttempts,
c.locked_until AS lockedUntil, u.id, u.user_type AS userType, u.status,
u.role_version AS roleVersion, u.nickname, u.avatar_url AS avatarUrl, u.phone
FROM qipai_admin_credentials c
INNER JOIN qipai_tenants t ON t.id = c.tenant_id AND t.status = 'ACTIVE' AND t.deleted_at IS NULL
INNER JOIN qipai_users u ON u.id = c.user_id AND u.tenant_id = c.tenant_id
AND u.user_type = 'STAFF' AND u.status = 'ACTIVE' AND u.deleted_at IS NULL
INNER JOIN qipai_tenant_apps ta ON ta.tenant_id = c.tenant_id
AND ta.status = 'ACTIVE' AND ta.deleted_at IS NULL
WHERE t.code = ? AND c.login_name = ? AND c.deleted_at IS NULL
AND EXISTS (SELECT 1 FROM qipai_user_roles ur
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
AND r.status = 'ACTIVE' AND r.code IN ('STAFF', 'CLEANER', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
WHERE ur.tenant_id = c.tenant_id AND ur.user_id = c.user_id)
ORDER BY ta.is_default DESC, ta.id ASC LIMIT 1`,
[input.tenantCode.trim(), loginName]
);
const row = rows[0];
const validPassword = await verifyPassword(input.password, row?.passwordHash);
if (!row || row.credentialStatus !== 'ACTIVE' || !validPassword) {
if (row) await this.recordFailure(row, loginName, input);
throw new AdminAuthError('ADMIN_LOGIN_INVALID');
}
if (row.lockedUntil && row.lockedUntil.getTime() > Date.now()) {
throw new AdminAuthError('ADMIN_LOGIN_LOCKED');
}
const user = mapUser(row);
await this.transaction(async (connection) => {
await connection.execute(
`UPDATE qipai_admin_credentials SET failed_attempts = 0, locked_until = NULL,
last_login_at = UTC_TIMESTAMP(3) WHERE id = ?`, [row.credentialId]
);
await connection.execute(
`INSERT INTO qipai_auth_sessions
(id, tenant_id, platform_app_id, user_id, role_version, refresh_token_hash,
expires_at, refresh_expires_at, ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[input.sessionId, row.tenantId, row.platformAppId, user.id, user.roleVersion,
input.refreshTokenHash, input.expiresAt, input.expiresAt, input.ip,
input.userAgent.slice(0, 255)]
);
await connection.execute(
'UPDATE qipai_users SET last_login_at = UTC_TIMESTAMP(3) WHERE tenant_id = ? AND id = ?',
[row.tenantId, user.id]
);
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, 'USER', ?, 'ADMIN_LOGIN_SUCCEEDED', 'USER', ?, ?, ?, ?, '{}')`,
[row.tenantId, user.id, user.id, input.traceId, input.ip, input.userAgent.slice(0, 255)]
);
});
return { id: input.sessionId, tenantId: String(row.tenantId),
platformAppId: String(row.platformAppId), user, expiresAt: input.expiresAt };
}
async rotateRefreshToken(input: {
sessionId: string; currentHash: string; nextHash: string; ip: string; userAgent: string;
}): Promise<AuthSession> {
const [rows] = await this.pool.execute<RefreshRow[]>(
`SELECT s.id AS sessionId, s.tenant_id AS tenantId, s.platform_app_id AS platformAppId,
s.refresh_token_hash AS refreshTokenHash, s.refresh_expires_at AS refreshExpiresAt,
u.id, u.user_type AS userType, u.status, u.role_version AS roleVersion,
u.nickname, u.avatar_url AS avatarUrl, u.phone
FROM qipai_auth_sessions s INNER JOIN qipai_users u
ON u.id = s.user_id AND u.tenant_id = s.tenant_id AND u.deleted_at IS NULL
WHERE s.id = ? AND s.status = 'ACTIVE' AND s.revoked_at IS NULL
AND s.refresh_token_hash IS NOT NULL AND s.refresh_expires_at > UTC_TIMESTAMP(3)
AND u.status = 'ACTIVE' AND u.role_version = s.role_version LIMIT 1`, [input.sessionId]
);
const row = rows[0];
if (!row || !safeHashEqual(row.refreshTokenHash, input.currentHash)) {
throw new AdminAuthError('ADMIN_REFRESH_INVALID');
}
const [updated] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_auth_sessions SET refresh_token_hash = ?, last_seen_at = UTC_TIMESTAMP(3),
ip = ?, user_agent = ? WHERE id = ? AND refresh_token_hash = ? AND status = 'ACTIVE'`,
[input.nextHash, input.ip, input.userAgent.slice(0, 255), input.sessionId, input.currentHash]
);
if (updated.affectedRows !== 1) throw new AdminAuthError('ADMIN_REFRESH_REUSED');
return { id: row.sessionId, tenantId: String(row.tenantId),
platformAppId: String(row.platformAppId), user: mapUser(row), expiresAt: row.refreshExpiresAt };
}
async setCredential(actor: ManagementActor, tenantId: string, input: {
userId: string; loginName: string; passwordHash: string;
}) {
const loginName = normalizeLoginName(input.loginName);
try {
return await this.transaction(async (connection) => {
const [users] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_users WHERE id = ? AND tenant_id = ? AND user_type = 'STAFF'
AND status = 'ACTIVE' AND deleted_at IS NULL FOR UPDATE`, [input.userId, tenantId]
);
if (!users[0]) throw new AdminAuthError('ADMIN_CREDENTIAL_USER_INVALID');
const [conflicts] = await connection.execute<RowDataPacket[]>(
`SELECT user_id AS userId FROM qipai_admin_credentials
WHERE tenant_id = ? AND login_name = ? AND user_id <> ? AND deleted_at IS NULL FOR UPDATE`,
[tenantId, loginName, input.userId]
);
if (conflicts[0]) throw new AdminAuthError('ADMIN_LOGIN_NAME_CONFLICT');
const [current] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_admin_credentials
WHERE tenant_id = ? AND user_id = ? FOR UPDATE`, [tenantId, input.userId]
);
if (current[0]) {
await connection.execute(
`UPDATE qipai_admin_credentials SET login_name = ?, password_hash = ?, status = 'ACTIVE',
failed_attempts = 0, locked_until = NULL, deleted_at = NULL,
password_changed_at = UTC_TIMESTAMP(3) WHERE id = ?`,
[loginName, input.passwordHash, current[0].id]
);
} else {
await connection.execute(
`INSERT INTO qipai_admin_credentials
(tenant_id, user_id, login_name, password_hash) VALUES (?, ?, ?, ?)`,
[tenantId, input.userId, loginName, input.passwordHash]
);
}
await connection.execute(
`UPDATE qipai_auth_sessions SET status = 'REVOKED', revoked_at = UTC_TIMESTAMP(3),
revoke_reason = 'PASSWORD_CHANGED' WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE'`,
[tenantId, input.userId]
);
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, 'USER', ?, 'ADMIN_CREDENTIAL_UPDATED', 'USER', ?, ?, ?, ?, ?)`,
[tenantId, actor.userId, input.userId, actor.traceId, actor.ip, actor.userAgent.slice(0, 255),
JSON.stringify({ loginNameHash: hashToken(loginName) })]
);
return { userId: input.userId, configured: true };
});
} catch (error) {
if ((error as { code?: string }).code === 'ER_DUP_ENTRY') {
throw new AdminAuthError('ADMIN_LOGIN_NAME_CONFLICT');
}
throw error;
}
}
private async recordFailure(row: CredentialRow, loginName: string, input: {
traceId: string; ip: string; userAgent: string;
}) {
await this.pool.execute(
`UPDATE qipai_admin_credentials SET failed_attempts = failed_attempts + 1,
locked_until = CASE WHEN failed_attempts + 1 >= 5
THEN DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 15 MINUTE) ELSE locked_until END
WHERE id = ?`, [row.credentialId]
);
await this.pool.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, 'SYSTEM', NULL, 'ADMIN_LOGIN_FAILED', 'ADMIN_CREDENTIAL', ?, ?, ?, ?, ?)`,
[row.tenantId, row.credentialId, input.traceId, input.ip, input.userAgent.slice(0, 255),
JSON.stringify({ loginNameHash: hashToken(loginName) })]
);
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; }
catch (error) { await connection.rollback(); throw error; } finally { connection.release(); }
}
}
export function normalizeLoginName(value: string) { return value.normalize('NFKC').trim().toLowerCase(); }
export function hashToken(value: string) { return createHash('sha256').update(value).digest('hex'); }
function safeHashEqual(left: string, right: string) {
const a = Buffer.from(left); const b = Buffer.from(right);
return a.length === b.length && timingSafeEqual(a, b);
}
function mapUser(row: { id: string; tenantId: string; userType: string; status: string;
roleVersion: number; nickname: string; avatarUrl: string; phone: string }): AuthUser {
return { id: String(row.id), tenantId: String(row.tenantId), userType: row.userType,
status: row.status, roleVersion: row.roleVersion, nickname: row.nickname,
avatarUrl: row.avatarUrl, phone: row.phone };
}
+46
View File
@@ -0,0 +1,46 @@
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
const algorithm = 'scrypt';
const cost = 16384;
const blockSize = 8;
const parallelization = 1;
const keyLength = 64;
const maxmem = 64 * 1024 * 1024;
const dummyHash = 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA$4fXWGR0BUXE6uC3v9sPYUjV_QlLQaq9EOrlgkxMVXngIWJ2unUaOv9OnZ47Z0RFKmK0LLBeMdNNB6W1RgPBplA';
export async function hashPassword(plainTextPassword: string): Promise<string> {
const salt = randomBytes(16);
const derived = await derive(plainTextPassword, salt, cost, blockSize, parallelization);
return [algorithm, cost, blockSize, parallelization,
salt.toString('base64url'), derived.toString('base64url')].join('$');
}
export async function verifyPassword(plainTextPassword: string, encoded = dummyHash): Promise<boolean> {
const parts = encoded.split('$');
if (parts.length !== 6 || parts[0] !== algorithm) {
await verifyPassword(plainTextPassword, dummyHash);
return false;
}
const [, nValue, rValue, pValue, saltValue, hashValue] = parts;
const n = Number(nValue); const r = Number(rValue); const p = Number(pValue);
if (n !== cost || r !== blockSize || p !== parallelization) {
await verifyPassword(plainTextPassword, dummyHash);
return false;
}
const salt = Buffer.from(saltValue, 'base64url');
const expected = Buffer.from(hashValue, 'base64url');
if (salt.length !== 16 || expected.length !== keyLength) {
await verifyPassword(plainTextPassword, dummyHash);
return false;
}
const actual = await derive(plainTextPassword, salt, n, r, p);
return timingSafeEqual(actual, expected);
}
function derive(plainTextPassword: string, salt: Buffer, N: number, r: number, p: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(plainTextPassword, salt, keyLength, { N, r, p, maxmem }, (error, derivedKey) => {
if (error) reject(error); else resolve(derivedKey);
});
});
}
+6 -2
View File
@@ -52,7 +52,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql',
'database/migrations/2026081001_m08c_staff_management_access.up.sql',
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
'database/migrations/2026081003_m08d_franchise_leads.up.sql'
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
'database/migrations/2026081004_m08d_admin_password_auth.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -86,9 +87,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql',
'database/migrations/2026081001_m08c_staff_management_access.verify.sql',
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
'database/migrations/2026081003_m08d_franchise_leads.verify.sql'
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql'
],
down: [
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
@@ -267,6 +270,7 @@ export async function executeMigrationPlan(
4, 1, 1, 1,
2, 1, 1,
1, 1, 1,
1, 2, 3, 1,
1, 2, 3, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
+142
View File
@@ -0,0 +1,142 @@
import { randomBytes, randomUUID } from 'node:crypto';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import { AdminAuthError, hashToken, type AdminAuthRepository } from '../auth/admin-auth-repository.js';
import { signAccessToken } from '../auth/jwt.js';
import { hashPassword } from '../auth/password.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const loginName = z.string().trim().min(3).max(64).regex(/^[\p{L}\p{N}._@+-]+$/u);
const strongPassword = z.string().min(12).max(128)
.refine((value) => /[A-Za-z]/.test(value) && /\d/.test(value) && /[^A-Za-z0-9]/.test(value));
const loginSchema = z.object({ tenantCode: z.string().trim().min(2).max(64), loginName,
['password']: z.string().min(1).max(128) }).strict();
const refreshSchema = z.object({ refreshToken: z.string().min(60).max(160) }).strict();
const credentialSchema = z.object({ tenantId: id.optional(), loginName, ['password']: strongPassword }).strict();
export interface AdminAuthRouteOptions {
repository: Pick<AdminAuthRepository, 'loginWithPassword' | 'rotateRefreshToken' | 'setCredential'>;
authRepository: Pick<AuthRepository, 'validateSession' | 'revokeSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
accessTokenTtlSeconds: number;
sessionTtlSeconds: number;
}
export async function registerAdminAuthRoutes(app: FastifyInstance, options: AdminAuthRouteOptions) {
app.post('/admin-api/auth/login', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
async (request, reply) => {
const body = loginSchema.safeParse(request.body);
if (!body.success) return invalid(reply, request.traceId);
const sessionId = randomUUID(); const refreshToken = createRefreshToken(sessionId);
try {
const session = await options.repository.loginWithPassword({ ...body.data, sessionId,
refreshTokenHash: hashToken(refreshToken),
expiresAt: new Date(Date.now() + options.sessionTtlSeconds * 1000),
ip: request.ip, userAgent: request.headers['user-agent'] ?? '', traceId: request.traceId });
const access = await options.accessControl.getAccessProfile(session.tenantId, session.user.id);
return { code: 0, data: sessionResponse(session, access, refreshToken, options), traceId: request.traceId };
} catch (error) { return authError(reply, request.traceId, error); }
});
app.post('/admin-api/auth/refresh', { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
async (request, reply) => {
const body = refreshSchema.safeParse(request.body);
if (!body.success) return invalid(reply, request.traceId);
const sessionId = body.data.refreshToken.split('.', 1)[0];
if (!z.string().uuid().safeParse(sessionId).success) return invalid(reply, request.traceId);
const nextRefreshToken = createRefreshToken(sessionId);
try {
const session = await options.repository.rotateRefreshToken({ sessionId,
currentHash: hashToken(body.data.refreshToken), nextHash: hashToken(nextRefreshToken),
ip: request.ip, userAgent: request.headers['user-agent'] ?? '' });
const access = await options.accessControl.getAccessProfile(session.tenantId, session.user.id);
return { code: 0, data: sessionResponse(session, access, nextRefreshToken, options), traceId: request.traceId };
} catch (error) { return authError(reply, request.traceId, error); }
});
app.get('/admin-api/auth/me', async (request, reply) => {
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
if (!auth) return unauthorized(reply, request.traceId);
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
return { code: 0, data: { user: safeUser(auth.session.user), access: adminAccess(access) }, traceId: request.traceId };
});
app.post('/admin-api/auth/logout', async (request, reply) => {
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
if (!auth) return unauthorized(reply, request.traceId);
await options.authRepository.revokeSession(auth.sessionId, 'ADMIN_LOGOUT');
return { code: 0, data: { revoked: true }, traceId: request.traceId };
});
app.put('/admin-api/auth/credentials/:userId', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const params = z.object({ userId: id }).safeParse(request.params);
const body = credentialSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
const tenantId = resolveTenant(actor, body.data.tenantId);
if (!tenantId) return reply.status(403).send({ code: 'ADMIN_AUTH_TENANT_FORBIDDEN',
message: 'The tenant is outside the allowed scope.', traceId: request.traceId });
try {
const passwordHash = await hashPassword(body.data.password);
return { code: 0, data: await options.repository.setCredential(actor, tenantId,
{ userId: params.data.userId, loginName: body.data.loginName, passwordHash }), traceId: request.traceId };
} catch (error) { return authError(reply, request.traceId, error); }
});
}
async function requireManager(request: FastifyRequest, reply: FastifyReply, options: AdminAuthRouteOptions) {
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
if (!auth) { unauthorized(reply, request.traceId); return null; }
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
if (!access.capabilities.includes('tenant.manage') && !isPlatform(access)) {
reply.status(403).send({ code: 'ADMIN_CREDENTIAL_FORBIDDEN',
message: 'Tenant management permission is required.', traceId: request.traceId }); return null;
}
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' };
}
function createRefreshToken(sessionId: string) { return `${sessionId}.${randomBytes(32).toString('base64url')}`; }
function sessionResponse(session: { id: string; tenantId: string; platformAppId: string; user: Parameters<typeof safeUser>[0] },
access: AccessProfile, refreshToken: string, options: AdminAuthRouteOptions) {
return { accessToken: signAccessToken({ sub: session.user.id, sid: session.id, tid: session.tenantId,
aid: session.platformAppId, rv: session.user.roleVersion }, options.jwtSecret, options.accessTokenTtlSeconds),
refreshToken, expiresIn: options.accessTokenTtlSeconds, user: safeUser(session.user),
access: adminAccess(access) };
}
function adminAccess(access: AccessProfile) {
const tenant = access.capabilities.includes('tenant.manage') || isPlatform(access);
const storeRead = tenant || access.capabilities.includes('store.operation.read');
const menus = [
...(storeRead ? ['overview', 'stores', 'orders', 'thirdParty'] : []),
...(tenant ? ['platformApps', 'content', 'franchise', 'system', 'payments', 'people'] : []),
...(tenant || access.capabilities.includes('device.read') ? ['devices'] : []),
...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : [])
];
return { ...access, menus: [...new Set(menus)] };
}
function safeUser(user: { id: string; tenantId: string; userType: string; nickname: string; avatarUrl: string; roleVersion: number }) {
return { id: user.id, tenantId: user.tenantId, userType: user.userType,
nickname: user.nickname, avatarUrl: user.avatarUrl, roleVersion: user.roleVersion };
}
function resolveTenant(actor: ManagementActor, requested?: string) {
if (!requested || requested === actor.tenantId) return actor.tenantId;
return isPlatform(actor.access) ? requested : null;
}
function isPlatform(access: AccessProfile) { return access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage'); }
function invalid(reply: FastifyReply, traceId: string) { return reply.status(400).send({
code: 'INVALID_ADMIN_AUTH_REQUEST', message: 'The admin authentication request is invalid.', traceId }); }
function unauthorized(reply: FastifyReply, traceId: string) { return reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'The admin session is invalid or expired.', traceId }); }
function authError(reply: FastifyReply, traceId: string, error: unknown) {
if (!(error instanceof AdminAuthError)) throw error;
const status = error.code === 'ADMIN_LOGIN_LOCKED' ? 423
: error.code.includes('CONFLICT') ? 409
: error.code.includes('USER_INVALID') ? 404 : 401;
return reply.status(status).send({ code: error.code,
message: 'The admin authentication request was rejected.', traceId });
}
+9
View File
@@ -43,6 +43,7 @@ import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
import { FranchiseRepository } from './franchise/franchise-repository.js';
import { SystemOperationsRepository } from './operations/system-operations-repository.js';
import { AdminAuthRepository } from './auth/admin-auth-repository.js';
const config = loadConfig();
const pool = createMySqlPool(config);
@@ -232,6 +233,14 @@ const app = await buildApp({
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
adminAuth: {
repository: new AdminAuthRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret,
accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds,
sessionTtlSeconds: config.auth.sessionTtlSeconds
}
});
app.addHook('onClose', async () => {
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { AdminAuthError } from '../dist/auth/admin-auth-repository.js';
import { hashPassword, verifyPassword } from '../dist/auth/password.js';
const secret = 'test-only-admin-password-auth-secret-32';
const user = { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1,
nickname: '租户管理员', avatarUrl: '', phone: '13800138000' };
let loginInput; let refreshInput; let credentialInput; let revokedSessionId;
const repository = {
async loginWithPassword(input) {
loginInput = input;
if (input.password === 'wrong-password') throw new AdminAuthError('ADMIN_LOGIN_INVALID');
if (input.password === 'locked-password') throw new AdminAuthError('ADMIN_LOGIN_LOCKED');
return { id: input.sessionId, tenantId: '7', platformAppId: '9', user, expiresAt: input.expiresAt };
},
async rotateRefreshToken(input) {
refreshInput = input;
return { id: input.sessionId, tenantId: '7', platformAppId: '9', user,
expiresAt: new Date(Date.now() + 60000) };
},
async setCredential(actor, tenantId, input) {
credentialInput = { actor, tenantId, input };
return { userId: input.userId, configured: true };
}
};
const authRepository = {
async validateSession(sessionId) {
return { id: sessionId, tenantId: '7', platformAppId: '9', user,
expiresAt: new Date(Date.now() + 60000) };
},
async revokeSession(sessionId) { revokedSessionId = sessionId; return true; }
};
const access = { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] };
const app = await buildApp({ adminAuth: { repository, authRepository,
accessControl: { async getAccessProfile() { return access; } }, jwtSecret: secret,
accessTokenTtlSeconds: 900, sessionTtlSeconds: 604800 } });
const login = await app.inject({ method: 'POST', url: '/admin-api/auth/login',
payload: { tenantCode: 'demo', loginName: 'Admin.User', ['password']: 'ValidPassword!123' } });
assert.equal(login.statusCode, 200);
assert.equal(loginInput.tenantCode, 'demo');
assert.equal(login.json().data.user.phone, undefined);
assert.deepEqual(login.json().data.access.roles, ['TENANT_ADMIN']);
assert.ok(login.json().data.access.menus.includes('system'));
assert.match(login.json().data.accessToken, /^[^.]+\.[^.]+\.[^.]+$/);
assert.match(login.json().data.refreshToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/);
const successfulSessionId = loginInput.sessionId;
const bad = await app.inject({ method: 'POST', url: '/admin-api/auth/login',
payload: { tenantCode: 'demo', loginName: 'admin', ['password']: 'wrong-password' } });
assert.equal(bad.statusCode, 401);
const locked = await app.inject({ method: 'POST', url: '/admin-api/auth/login',
payload: { tenantCode: 'demo', loginName: 'admin', ['password']: 'locked-password' } });
assert.equal(locked.statusCode, 423);
const refreshed = await app.inject({ method: 'POST', url: '/admin-api/auth/refresh',
payload: { refreshToken: login.json().data.refreshToken } });
assert.equal(refreshed.statusCode, 200);
assert.equal(refreshInput.sessionId, successfulSessionId);
assert.notEqual(refreshed.json().data.refreshToken, login.json().data.refreshToken);
const auth = { authorization: `Bearer ${login.json().data.accessToken}` };
const me = await app.inject({ method: 'GET', url: '/admin-api/auth/me', headers: auth });
assert.equal(me.statusCode, 200);
assert.equal(me.json().data.user.nickname, '租户管理员');
const weakCredential = await app.inject({ method: 'PUT', url: '/admin-api/auth/credentials/22',
headers: auth, payload: { loginName: 'operator', ['password']: 'too-weak' } });
assert.equal(weakCredential.statusCode, 400);
const credential = await app.inject({ method: 'PUT', url: '/admin-api/auth/credentials/22',
headers: auth, payload: { loginName: 'Operator.22', ['password']: 'StrongPassword!2026' } });
assert.equal(credential.statusCode, 200);
assert.equal(credentialInput.tenantId, '7');
assert.equal(credentialInput.input.loginName, 'Operator.22');
assert.match(credentialInput.input.passwordHash, /^scrypt\$16384\$8\$1\$/);
const logout = await app.inject({ method: 'POST', url: '/admin-api/auth/logout', headers: auth });
assert.equal(logout.statusCode, 200);
assert.equal(revokedSessionId, successfulSessionId);
await app.close();
const passwordHash = await hashPassword('StrongPassword!2026');
assert.equal(await verifyPassword('StrongPassword!2026', passwordHash), true);
assert.equal(await verifyPassword('WrongPassword!2026', passwordHash), false);
assert.equal(await verifyPassword('anything', 'invalid-hash'), false);
console.log('PASS: M08-D admin password login, rotating refresh, session cleanup and credential setup are present.');
@@ -105,6 +105,9 @@ const contentAssetScopeVerifySql = read('database/migrations/2026081002_m08d_con
const franchiseUpSql = read('database/migrations/2026081003_m08d_franchise_leads.up.sql');
const franchiseDownSql = read('database/migrations/2026081003_m08d_franchise_leads.down.sql');
const franchiseVerifySql = read('database/migrations/2026081003_m08d_franchise_leads.verify.sql');
const adminAuthUpSql = read('database/migrations/2026081004_m08d_admin_password_auth.up.sql');
const adminAuthDownSql = read('database/migrations/2026081004_m08d_admin_password_auth.down.sql');
const adminAuthVerifySql = read('database/migrations/2026081004_m08d_admin_password_auth.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -480,6 +483,12 @@ assert.match(franchiseUpSql, /CREATE TABLE IF NOT EXISTS qipai_franchise_applica
assert.match(franchiseUpSql, /CREATE TABLE IF NOT EXISTS qipai_franchise_follow_ups/);
assert.match(franchiseUpSql, /uq_qipai_franchise_client_request/);
assert.match(franchiseUpSql, /'2026081003'/);
assert.match(adminAuthUpSql, /CREATE TABLE IF NOT EXISTS qipai_admin_credentials/);
assert.match(adminAuthUpSql, /refresh_token_hash CHAR\(64\)/);
assert.match(adminAuthUpSql, /uq_qipai_admin_credentials_login/);
assert.match(adminAuthUpSql, /'2026081004'/);
assert.match(adminAuthDownSql, /DROP TABLE IF EXISTS qipai_admin_credentials/);
assert.match(adminAuthVerifySql, /idx_qipai_auth_sessions_refresh/);
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
+4 -2
View File
@@ -42,14 +42,16 @@ assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.up\.sql/);
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql$/);
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
const verifyPlan = await loadMigrationPlan('verify');
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql$/);
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql$/);
const calls = [];
const fakePool = {
@@ -43,6 +43,8 @@ import { DeviceControlService } from '../dist/devices/device-control-service.js'
import { MemberProfileService } from '../dist/wallets/member-profile-service.js';
import { BusinessStatisticsRepository } from '../dist/operations/business-statistics-repository.js';
import { SystemOperationsRepository } from '../dist/operations/system-operations-repository.js';
import { AdminAuthRepository, AdminAuthError, hashToken } from '../dist/auth/admin-auth-repository.js';
import { hashPassword } from '../dist/auth/password.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -50,6 +52,7 @@ import {
} from '../dist/db/migration-runner.js';
const expectedTables = [
'qipai_admin_credentials',
'qipai_advertisements',
'qipai_async_tasks',
'qipai_audit_logs',
@@ -133,13 +136,13 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220', '2026081002', '2026081003']
'2026062220', '2026081002', '2026081003', '2026081004']
);
return rows;
}
@@ -1827,7 +1830,7 @@ async function assertSystemOperations(pool, context) {
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
const overview = await repository.getSystemOverview(context.tenantId);
assert.equal(overview.tenant.id, context.tenantId);
assert.equal(overview.latestMigration.version, '2026081003');
assert.equal(overview.latestMigration.version, '2026081004');
assert.ok(overview.counts.userCount > 0);
await repository.updateTenant(actor, context.tenantId, {
name: overview.tenant.name, timezone: overview.tenant.timezone
@@ -1839,6 +1842,59 @@ async function assertSystemOperations(pool, context) {
assert.equal(updatedLogs.items[0].actorId, adminId);
}
async function assertAdminPasswordAuth(pool, context) {
const [tenantRows] = await pool.query('SELECT code FROM qipai_tenants WHERE id = ?', [context.tenantId]);
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`, [context.tenantId]
);
const userId = String(adminRows[0].id);
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, userId);
const actor = { tenantId: context.tenantId, userId, access,
traceId: 'm08d-admin-auth-live', ip: '127.0.0.1', userAgent: 'M08-D admin auth live test' };
const repository = new AdminAuthRepository(pool);
const passwordHash = await hashPassword('LiveAdminPassword!2026');
await repository.setCredential(actor, context.tenantId, {
userId, loginName: 'Live.Admin', passwordHash
});
const [credentialRows] = await pool.query(
'SELECT login_name AS loginName, password_hash AS passwordHash FROM qipai_admin_credentials WHERE tenant_id = ? AND user_id = ?',
[context.tenantId, userId]
);
assert.equal(credentialRows[0].loginName, 'live.admin');
assert.notEqual(credentialRows[0].passwordHash, 'LiveAdminPassword!2026');
await assert.rejects(() => repository.loginWithPassword({ tenantCode: tenantRows[0].code,
loginName: 'live.admin', ['password']: 'WrongPassword!2026', sessionId: '11000000-0000-4000-8000-000000000001',
refreshTokenHash: hashToken('unused'), expiresAt: new Date(Date.now() + 60000),
ip: actor.ip, userAgent: actor.userAgent, traceId: 'm08d-admin-login-failed' }),
(error) => error instanceof AdminAuthError && error.code === 'ADMIN_LOGIN_INVALID');
const refreshToken = '11000000-0000-4000-8000-000000000002.live-refresh-token';
const session = await repository.loginWithPassword({ tenantCode: tenantRows[0].code,
loginName: 'LIVE.ADMIN', ['password']: 'LiveAdminPassword!2026',
sessionId: '11000000-0000-4000-8000-000000000002', refreshTokenHash: hashToken(refreshToken),
expiresAt: new Date(Date.now() + 60000), ip: actor.ip, userAgent: actor.userAgent,
traceId: 'm08d-admin-login-success' });
assert.equal(session.user.id, userId);
const nextRefreshToken = '11000000-0000-4000-8000-000000000002.next-refresh-token';
const refreshed = await repository.rotateRefreshToken({ sessionId: session.id,
currentHash: hashToken(refreshToken), nextHash: hashToken(nextRefreshToken),
ip: actor.ip, userAgent: actor.userAgent });
assert.equal(refreshed.user.id, userId);
await assert.rejects(() => repository.rotateRefreshToken({ sessionId: session.id,
currentHash: hashToken(refreshToken), nextHash: hashToken('reused'),
ip: actor.ip, userAgent: actor.userAgent }),
(error) => error instanceof AdminAuthError && error.code === 'ADMIN_REFRESH_INVALID');
const [auditRows] = await pool.query(
`SELECT action, CAST(metadata AS CHAR) AS metadata FROM qipai_audit_logs
WHERE tenant_id = ? AND action IN ('ADMIN_CREDENTIAL_UPDATED', 'ADMIN_LOGIN_FAILED', 'ADMIN_LOGIN_SUCCEEDED')`,
[context.tenantId]
);
assert.ok(auditRows.some((row) => row.action === 'ADMIN_LOGIN_SUCCEEDED'));
assert.equal(auditRows.some((row) => row.metadata.includes('LiveAdminPassword!2026')), false);
}
async function assertDeviceTopology(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
@@ -2118,7 +2174,8 @@ try {
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' }
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -2128,6 +2185,7 @@ try {
await assertContentManagement(pool, loginContext);
await assertFranchiseManagement(pool, loginContext);
await assertSystemOperations(pool, loginContext);
await assertAdminPasswordAuth(pool, loginContext);
await assertStoreDiscovery(pool, loginContext);
await assertSceneAndWifiAccess(pool, loginContext);
await assertPricingAndReservations(pool, loginContext);
@@ -2172,7 +2230,8 @@ try {
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' }
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -2222,6 +2281,8 @@ try {
'franchise assignment and controlled follow-up status transition',
'tenant-scoped audit filtering with recursive sensitive metadata redaction',
'system overview and audited tenant configuration update',
'scrypt admin credentials and failed-login audit',
'rotating refresh token with reuse rejection',
'city fallback store filtering',
'server-side distance sorting',
'empty manual city result',
@@ -0,0 +1,6 @@
DELETE FROM qipai_schema_migrations WHERE version = '2026081004';
ALTER TABLE qipai_auth_sessions
DROP INDEX idx_qipai_auth_sessions_refresh,
DROP COLUMN refresh_expires_at,
DROP COLUMN refresh_token_hash;
DROP TABLE IF EXISTS qipai_admin_credentials;
@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS qipai_admin_credentials (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
login_name VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
failed_attempts INT UNSIGNED NOT NULL DEFAULT 0,
locked_until DATETIME(3) NULL,
password_changed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
last_login_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL,
CONSTRAINT fk_qipai_admin_credentials_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_admin_credentials_user FOREIGN KEY (user_id) REFERENCES qipai_users(id),
UNIQUE KEY uq_qipai_admin_credentials_login (tenant_id, login_name),
UNIQUE KEY uq_qipai_admin_credentials_user (tenant_id, user_id),
KEY idx_qipai_admin_credentials_status (tenant_id, status, locked_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE qipai_auth_sessions
ADD COLUMN refresh_token_hash CHAR(64) NULL AFTER role_version,
ADD COLUMN refresh_expires_at DATETIME(3) NULL AFTER expires_at,
ADD KEY idx_qipai_auth_sessions_refresh (status, refresh_expires_at);
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026081004', 'm08d_admin_password_auth');
@@ -0,0 +1,25 @@
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'qipai_admin_credentials';
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_auth_sessions'
AND column_name IN ('refresh_token_hash', 'refresh_expires_at')
ORDER BY column_name;
SELECT table_name, index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND ((table_name = 'qipai_admin_credentials'
AND index_name IN ('uq_qipai_admin_credentials_login', 'uq_qipai_admin_credentials_user'))
OR (table_name = 'qipai_auth_sessions'
AND index_name = 'idx_qipai_auth_sessions_refresh'))
GROUP BY table_name, index_name
ORDER BY table_name, index_name;
SELECT version, name
FROM qipai_schema_migrations
WHERE version = '2026081004';
+22
View File
@@ -31,8 +31,11 @@ for (const pattern of [
'ContentManagementPanel',
'FranchisePanel',
'LogsSystemPanel',
'AdminLoginPanel',
'运营总览',
'savedToken',
'handleAuthenticated',
'handleLogout',
'loadCleaningWorkspace'
]) {
assert.match(app, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
@@ -234,6 +237,23 @@ const systemRepository = read('backend/src/operations/system-operations-reposito
assert.match(systemRepository, /sanitizeMetadata/);
assert.match(systemRepository, /TENANT_SYSTEM_CONFIG_UPDATED/);
const adminLogin = read('admin/src/components/AdminLoginPanel.vue');
for (const pattern of ['adminPasswordLogin', '租户代码', '安全登录', '连续失败 5 次']) {
assert.match(adminLogin, new RegExp(pattern));
}
const adminAuthRoutes = read('backend/src/routes/admin-auth.ts');
for (const pattern of ['/admin-api/auth/login', '/admin-api/auth/refresh', '/admin-api/auth/me',
'/admin-api/auth/logout', '/admin-api/auth/credentials/:userId']) {
assert.match(adminAuthRoutes, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}
const adminAuthRepository = read('backend/src/auth/admin-auth-repository.ts');
assert.match(adminAuthRepository, /ADMIN_LOGIN_SUCCEEDED/);
assert.match(adminAuthRepository, /ADMIN_LOGIN_FAILED/);
assert.match(adminAuthRepository, /ADMIN_REFRESH_REUSED/);
const adminAuthMigration = read('database/migrations/2026081004_m08d_admin_password_auth.up.sql');
assert.match(adminAuthMigration, /qipai_admin_credentials/);
assert.match(adminAuthMigration, /refresh_token_hash/);
const routes = read('backend/src/routes/business-statistics.ts');
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
@@ -278,6 +298,8 @@ for (const pattern of [
'.franchise-metrics',
'.system-metrics',
'.audit-filters',
'.login-shell',
'.session-box',
'@media (max-width: 980px)',
'@media (max-width: 560px)'
]) {