增加后台会话撤销并统一拼单业务状态
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 { 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]',
|
||||
|
||||
@@ -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<AdminSess
|
||||
}
|
||||
|
||||
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)
|
||||
if (!user || user.status !== 'active') {
|
||||
throw createHttpError('后台账号已不可用,请重新登录', {
|
||||
@@ -190,7 +224,7 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: String(payload?.sid || '').trim(),
|
||||
sessionId,
|
||||
userId: Number(user.id),
|
||||
username: String(user.username || ''),
|
||||
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> {
|
||||
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: {
|
||||
|
||||
@@ -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('选中的订单包含非待验收状态,请刷新后重试', {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function AdminLayout() {
|
||||
try {
|
||||
await logoutAdmin()
|
||||
} catch {
|
||||
// 后端退出是无状态的,本地清理优先。
|
||||
// 服务端撤销失败时仍清理本地登录态,避免当前设备继续显示为已登录。
|
||||
} finally {
|
||||
clearAdminSession()
|
||||
message.success('已退出后台')
|
||||
|
||||
Reference in New Issue
Block a user