增加后台会话撤销并统一拼单业务状态
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export default function AdminLayout() {
|
|||||||
try {
|
try {
|
||||||
await logoutAdmin()
|
await logoutAdmin()
|
||||||
} catch {
|
} catch {
|
||||||
// 后端退出是无状态的,本地清理优先。
|
// 服务端撤销失败时仍清理本地登录态,避免当前设备继续显示为已登录。
|
||||||
} finally {
|
} finally {
|
||||||
clearAdminSession()
|
clearAdminSession()
|
||||||
message.success('已退出后台')
|
message.success('已退出后台')
|
||||||
|
|||||||
Reference in New Issue
Block a user