增加后台会话撤销并统一拼单业务状态

This commit is contained in:
yml2213
2026-08-18 18:36:48 +08:00
parent 4867861b8b
commit 323b9ea34c
7 changed files with 164 additions and 13 deletions
@@ -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 '后台登录会话,支持单设备退出和服务端撤销';
@@ -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<AdminSessionRow | null> {
const result = await query<AdminSessionRow>(
`
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<AdminSessionRow | null> {
const result = await query<AdminSessionRow>(
`
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<boolean> {
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<boolean> {
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
}
+6 -2
View File
@@ -1,7 +1,11 @@
import { Router } from 'express' import { Router } from 'express'
import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js' 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 { import {
resolveClientIp, resolveClientIp,
resolveClientLocation, resolveClientLocation,
@@ -45,7 +49,7 @@ router.get(
router.post( router.post(
'/auth/logout', '/auth/logout',
createJsonHandler(() => ({ success: true }), { createJsonHandler((req) => logoutAdmin(extractBearerToken(req)), {
successMessage: '已退出登录', successMessage: '已退出登录',
errorMessage: '后台退出失败', errorMessage: '后台退出失败',
scope: '[admin/auth/logout]', scope: '[admin/auth/logout]',
@@ -10,6 +10,12 @@ import {
listAdminUsers, listAdminUsers,
updateAdminUser, updateAdminUser,
} from '../../repositories/admin-user-repo.js' } 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 { addHours, nowIso } from '../../utils/time.js'
import { createHttpError } from '../../utils/http.js' import { createHttpError } from '../../utils/http.js'
import { normalizePage, normalizePageSize } from './admin-query-utils.js' import { normalizePage, normalizePageSize } from './admin-query-utils.js'
@@ -113,6 +119,19 @@ export async function loginAdmin(
} }
const session = createAdminSession(user) 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({ await recordAdminLoginLog({
userId: Number(user.id), userId: Number(user.id),
username: String(user.username || normalizedUsername), username: String(user.username || normalizedUsername),
@@ -172,6 +191,21 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
} }
const userId = Number(payload?.uid || 0) const userId = Number(payload?.uid || 0)
const sessionId = String(payload?.sid || '').trim()
if (!sessionId) {
throw createHttpError('后台登录态无效', {
statusCode: 401,
errorCode: 'admin_auth_invalid',
})
}
const activeSession = await getActiveAdminSession(sessionId, nowIso())
if (!activeSession || Number(activeSession.user_id) !== userId) {
throw createHttpError('后台登录态已失效,请重新登录', {
statusCode: 401,
errorCode: 'admin_auth_revoked',
})
}
await touchAdminSession(sessionId, nowIso())
const user = await getAdminUserById(userId) const user = await getAdminUserById(userId)
if (!user || user.status !== 'active') { if (!user || user.status !== 'active') {
throw createHttpError('后台账号已不可用,请重新登录', { throw createHttpError('后台账号已不可用,请重新登录', {
@@ -190,7 +224,7 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
} }
return { return {
sessionId: String(payload?.sid || '').trim(), sessionId,
userId: Number(user.id), userId: Number(user.id),
username: String(user.username || ''), username: String(user.username || ''),
role: normalizeAdminRole(user.role), role: normalizeAdminRole(user.role),
@@ -199,6 +233,12 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
} }
} }
export async function logoutAdmin(token: unknown): Promise<{ success: boolean; revoked: boolean }> {
const session = await verifyAdminSessionToken(token)
const revoked = await revokeAdminSession(session.sessionId, nowIso())
return { success: true, revoked }
}
export async function getAdminSessionSummary(token: unknown): Promise<JsonObject> { export async function getAdminSessionSummary(token: unknown): Promise<JsonObject> {
const session = await verifyAdminSessionToken(token) const session = await verifyAdminSessionToken(token)
@@ -421,6 +461,8 @@ function createAdminSession(user: AdminUserRow): JsonObject {
const signature = signPayload(encodedPayload) const signature = signPayload(encodedPayload)
return { return {
sessionId: payload.sid,
issuedAt,
token: `${encodedPayload}.${signature}`, token: `${encodedPayload}.${signature}`,
expiresAt, expiresAt,
user: { user: {
@@ -136,6 +136,7 @@ import {
resolveWorkOrderRulePricing, resolveWorkOrderRulePricing,
resolveWorkOrderSharingEnabled, resolveWorkOrderSharingEnabled,
resolveWorkerPermissions, resolveWorkerPermissions,
resolveWorkOrderOperationalStatus,
} from './mappers.js' } from './mappers.js'
import { import {
ensureWorkerPlatformDefaults, ensureWorkerPlatformDefaults,
@@ -2059,7 +2060,8 @@ export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName
) )
if ( if (
workOrders.some( workOrders.some(
(workOrder) => mapWorkOrderAdmin(workOrder).status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE, (workOrder) =>
resolveWorkOrderOperationalStatus(workOrder) !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
) )
) { ) {
throw createHttpError('选中的订单包含非待验收状态,请刷新后重试', { throw createHttpError('选中的订单包含非待验收状态,请刷新后重试', {
@@ -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) { export function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0)) const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0))
const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 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 hasActiveShares = activeShareQuantity > 0
const sharingFilled = const sharingFilled =
workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity
const status = const status = resolveWorkOrderOperationalStatus(workOrder)
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 hallAvailable = const hallAvailable =
workOrder.status === WORK_ORDER_STATUS.OPEN && workOrder.status === WORK_ORDER_STATUS.OPEN &&
(!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity) (!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity)
+1 -1
View File
@@ -145,7 +145,7 @@ export default function AdminLayout() {
try { try {
await logoutAdmin() await logoutAdmin()
} catch { } catch {
// 后端退出是无状态的,本地清理优先 // 服务端撤销失败时仍清理本地登录态,避免当前设备继续显示为已登录
} finally { } finally {
clearAdminSession() clearAdminSession()
message.success('已退出后台') message.success('已退出后台')