diff --git a/apps/backend/src/db/migrations/040_admin_sessions.sql b/apps/backend/src/db/migrations/040_admin_sessions.sql new file mode 100644 index 00000000..064e2d6f --- /dev/null +++ b/apps/backend/src/db/migrations/040_admin_sessions.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS admin_sessions ( + id BIGSERIAL PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'active', + issued_at TIMESTAMPTZ NOT NULL, + last_seen_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_admin_sessions_user_status + ON admin_sessions(user_id, status, expires_at DESC); + +COMMENT ON TABLE admin_sessions IS '后台登录会话,支持单设备退出和服务端撤销'; diff --git a/apps/backend/src/repositories/admin-session-repo.ts b/apps/backend/src/repositories/admin-session-repo.ts new file mode 100644 index 00000000..47594b61 --- /dev/null +++ b/apps/backend/src/repositories/admin-session-repo.ts @@ -0,0 +1,76 @@ +import { query } from '../db/client.js' + +export type AdminSessionRow = { + id: number + session_id: string + user_id: number + status: string + issued_at: string + last_seen_at: string + expires_at: string + revoked_at: string | null + created_at: string + updated_at: string +} + +export async function createAdminSession(input: { + sessionId: string + userId: number + issuedAt: string + expiresAt: string + now: string +}): Promise { + const result = await query( + ` + INSERT INTO admin_sessions ( + session_id, user_id, status, issued_at, last_seen_at, + expires_at, revoked_at, created_at, updated_at + ) VALUES ($1, $2, 'active', $3, $3, $4, NULL, $5, $5) + RETURNING * + `, + [input.sessionId, input.userId, input.issuedAt, input.expiresAt, input.now], + ) + return result.rows[0] || null +} + +export async function getActiveAdminSession( + sessionId: string, + now: string, +): Promise { + const result = await query( + ` + SELECT * + FROM admin_sessions + WHERE session_id = $1 + AND status = 'active' + AND expires_at > $2 + LIMIT 1 + `, + [sessionId, now], + ) + return result.rows[0] || null +} + +export async function touchAdminSession(sessionId: string, now: string): Promise { + const result = await query( + ` + UPDATE admin_sessions + SET last_seen_at = $2, updated_at = $2 + WHERE session_id = $1 AND status = 'active' AND expires_at > $2 + `, + [sessionId, now], + ) + return result.rowCount === 1 +} + +export async function revokeAdminSession(sessionId: string, now: string): Promise { + const result = await query( + ` + UPDATE admin_sessions + SET status = 'revoked', revoked_at = $2, updated_at = $2 + WHERE session_id = $1 AND status = 'active' + `, + [sessionId, now], + ) + return result.rowCount === 1 +} diff --git a/apps/backend/src/routes/admin/auth.ts b/apps/backend/src/routes/admin/auth.ts index a01937cd..c8b17c6c 100644 --- a/apps/backend/src/routes/admin/auth.ts +++ b/apps/backend/src/routes/admin/auth.ts @@ -1,7 +1,11 @@ import { Router } from 'express' import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js' -import { getAdminSessionSummary, loginAdmin } from '../../services/admin/admin-auth-service.js' +import { + getAdminSessionSummary, + loginAdmin, + logoutAdmin, +} from '../../services/admin/admin-auth-service.js' import { resolveClientIp, resolveClientLocation, @@ -45,7 +49,7 @@ router.get( router.post( '/auth/logout', - createJsonHandler(() => ({ success: true }), { + createJsonHandler((req) => logoutAdmin(extractBearerToken(req)), { successMessage: '已退出登录', errorMessage: '后台退出失败', scope: '[admin/auth/logout]', diff --git a/apps/backend/src/services/admin/admin-auth-service.ts b/apps/backend/src/services/admin/admin-auth-service.ts index 431180b1..d67a164e 100644 --- a/apps/backend/src/services/admin/admin-auth-service.ts +++ b/apps/backend/src/services/admin/admin-auth-service.ts @@ -10,6 +10,12 @@ import { listAdminUsers, updateAdminUser, } from '../../repositories/admin-user-repo.js' +import { + createAdminSession as createAdminSessionRecord, + getActiveAdminSession, + revokeAdminSession, + touchAdminSession, +} from '../../repositories/admin-session-repo.js' import { addHours, nowIso } from '../../utils/time.js' import { createHttpError } from '../../utils/http.js' import { normalizePage, normalizePageSize } from './admin-query-utils.js' @@ -113,6 +119,19 @@ export async function loginAdmin( } const session = createAdminSession(user) + const createdSession = await createAdminSessionRecord({ + sessionId: String(session.sessionId || ''), + userId: Number(user.id), + issuedAt: String(session.issuedAt), + expiresAt: String(session.expiresAt), + now: nowIso(), + }) + if (!createdSession) { + throw createHttpError('后台登录会话创建失败,请重试', { + statusCode: 503, + errorCode: 'admin_session_create_failed', + }) + } await recordAdminLoginLog({ userId: Number(user.id), username: String(user.username || normalizedUsername), @@ -172,6 +191,21 @@ export async function verifyAdminSessionToken(token: unknown): Promise { + const session = await verifyAdminSessionToken(token) + const revoked = await revokeAdminSession(session.sessionId, nowIso()) + return { success: true, revoked } +} + export async function getAdminSessionSummary(token: unknown): Promise { const session = await verifyAdminSessionToken(token) @@ -421,6 +461,8 @@ function createAdminSession(user: AdminUserRow): JsonObject { const signature = signPayload(encodedPayload) return { + sessionId: payload.sid, + issuedAt, token: `${encodedPayload}.${signature}`, expiresAt, user: { diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index b6e8a846..dfdbd318 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -136,6 +136,7 @@ import { resolveWorkOrderRulePricing, resolveWorkOrderSharingEnabled, resolveWorkerPermissions, + resolveWorkOrderOperationalStatus, } from './mappers.js' import { ensureWorkerPlatformDefaults, @@ -2059,7 +2060,8 @@ export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName ) if ( workOrders.some( - (workOrder) => mapWorkOrderAdmin(workOrder).status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE, + (workOrder) => + resolveWorkOrderOperationalStatus(workOrder) !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE, ) ) { throw createHttpError('选中的订单包含非待验收状态,请刷新后重试', { diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index ae5d90ca..1482c6e3 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -324,6 +324,23 @@ export function mapWorkOrderMaterial(value: unknown) { } } +/** 统一计算后台使用的工单业务状态,避免拼单订单继续直接暴露母工单 open。 */ +export function resolveWorkOrderOperationalStatus(workOrder: WorkOrderRow): string { + const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0)) + const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0)) + const sharingTotalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) + const hasActiveShares = activeShareQuantity > 0 + const sharingFilled = + workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity + + if (workOrder.status === WORK_ORDER_STATUS.OPEN && workOrder.sharing_enabled && hasActiveShares) { + return sharingFilled && pendingShareCount === 0 + ? WORK_ORDER_STATUS.PENDING_ACCEPTANCE + : WORK_ORDER_STATUS.IN_PROGRESS + } + return workOrder.status +} + export function mapWorkOrderAdmin(workOrder: WorkOrderRow) { const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0)) const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0)) @@ -331,14 +348,7 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow) { const hasActiveShares = activeShareQuantity > 0 const sharingFilled = workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity - const status = - workOrder.status === WORK_ORDER_STATUS.OPEN && - workOrder.sharing_enabled === true && - hasActiveShares - ? sharingFilled && pendingShareCount === 0 - ? WORK_ORDER_STATUS.PENDING_ACCEPTANCE - : WORK_ORDER_STATUS.IN_PROGRESS - : workOrder.status + const status = resolveWorkOrderOperationalStatus(workOrder) const hallAvailable = workOrder.status === WORK_ORDER_STATUS.OPEN && (!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity) diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index c20bacdb..3dbebfa4 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -145,7 +145,7 @@ export default function AdminLayout() { try { await logoutAdmin() } catch { - // 后端退出是无状态的,本地清理优先。 + // 服务端撤销失败时仍清理本地登录态,避免当前设备继续显示为已登录。 } finally { clearAdminSession() message.success('已退出后台')