From b832279c1f6e07f36f0a834d02c52749ba6b0026 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 30 Aug 2026 21:52:08 +0800 Subject: [PATCH] feat: split worker login device limits by type --- apps/backend/scripts/check-sql-guard.ts | 26 ++ .../069_worker_session_device_type.sql | 8 + .../worker-platform/worker-session-repo.ts | 4 +- .../worker-auth-policy.test.ts | 11 + .../worker-platform/worker-auth-policy.ts | 2 +- .../worker-platform/worker-login-service.ts | 12 +- .../worker-session-auth-service.ts | 10 + .../src/pages/worker/WorkerLoginPage.tsx | 2 +- .../worker/WorkerProfileAccountModals.tsx | 4 +- .../src/pages/worker/WorkerProfilePage.tsx | 1 + apps/frontend/src/services/worker.ts | 14 +- device-limit-artifacts/DIFF_FILE.patch | 229 ++++++++++++++++++ device-limit-artifacts/MODIFIED_FILE.ts | 140 +++++++++++ device-limit-artifacts/ROLLBACK.sh | 24 ++ device-limit-artifacts/VERIFICATION.txt | 64 +++++ 15 files changed, 537 insertions(+), 14 deletions(-) create mode 100644 apps/backend/src/db/migrations/069_worker_session_device_type.sql create mode 100644 apps/backend/src/services/worker-platform/worker-auth-policy.test.ts create mode 100644 device-limit-artifacts/DIFF_FILE.patch create mode 100644 device-limit-artifacts/MODIFIED_FILE.ts create mode 100755 device-limit-artifacts/ROLLBACK.sh create mode 100644 device-limit-artifacts/VERIFICATION.txt diff --git a/apps/backend/scripts/check-sql-guard.ts b/apps/backend/scripts/check-sql-guard.ts index 859fcb76..5bfbeaaf 100644 --- a/apps/backend/scripts/check-sql-guard.ts +++ b/apps/backend/scripts/check-sql-guard.ts @@ -4,6 +4,7 @@ */ import fs from 'node:fs' import path from 'node:path' +import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) @@ -57,6 +58,31 @@ const checks: Array<{ name: string; file: string; patterns: string[] }> = [ ] const failures: string[] = [] + +// 已应用的历史迁移必须保持字节不变,所有结构或数据修复都必须新增迁移文件。 +try { + const diffOutputs = [ + execFileSync('git', ['diff', '--name-status', 'HEAD', '--', 'src/db/migrations'], { + cwd: BACKEND_ROOT, + encoding: 'utf8', + }), + execFileSync('git', ['diff', '--name-status', 'HEAD^', 'HEAD', '--', 'src/db/migrations'], { + cwd: BACKEND_ROOT, + encoding: 'utf8', + }), + ] + const changedHistoricalMigrations = diffOutputs + .flatMap((output) => output.split('\n')) + .map((line) => line.trim().split(/\s+/)) + .filter(([status, file]) => status && file?.endsWith('.sql') && status !== 'A') + .map(([status, file]) => `${status} ${file}`) + if (changedHistoricalMigrations.length > 0) { + failures.push(`历史迁移文件不可修改: ${changedHistoricalMigrations.join(', ')}`) + } +} catch (error) { + failures.push(`历史迁移完整性检查失败: ${error instanceof Error ? error.message : String(error)}`) +} + for (const check of checks) { if (!fs.existsSync(check.file)) { failures.push(`${check.name}: 文件不存在 ${check.file}`) diff --git a/apps/backend/src/db/migrations/069_worker_session_device_type.sql b/apps/backend/src/db/migrations/069_worker_session_device_type.sql new file mode 100644 index 00000000..033b921e --- /dev/null +++ b/apps/backend/src/db/migrations/069_worker_session_device_type.sql @@ -0,0 +1,8 @@ +-- 将历史平板会话归入手机设备桶,确保新旧数据使用同一套 PC/手机上限。 +UPDATE worker_sessions +SET device_type = 'mobile', updated_at = NOW() +WHERE device_type = 'tablet'; + +UPDATE worker_sessions +SET device_type = 'pc', updated_at = NOW() +WHERE device_type IS NULL OR device_type NOT IN ('pc', 'mobile'); diff --git a/apps/backend/src/repositories/worker-platform/worker-session-repo.ts b/apps/backend/src/repositories/worker-platform/worker-session-repo.ts index c59eb71d..fa1eaf1b 100644 --- a/apps/backend/src/repositories/worker-platform/worker-session-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-session-repo.ts @@ -41,9 +41,9 @@ export async function createWorkerSessionRecord( ) const activeResult = await client.query( `SELECT * FROM worker_sessions - WHERE worker_id = $1 AND status = 'active' AND expires_at > $2 + WHERE worker_id = $1 AND device_type = $2 AND status = 'active' AND expires_at > $3 ORDER BY last_seen_at DESC, id DESC`, - [input.workerId, input.now], + [input.workerId, input.deviceType, input.now], ) const activeSessions = activeResult.rows if (activeSessions.length >= input.maxDevices) { diff --git a/apps/backend/src/services/worker-platform/worker-auth-policy.test.ts b/apps/backend/src/services/worker-platform/worker-auth-policy.test.ts new file mode 100644 index 00000000..2da1ff5e --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-auth-policy.test.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { normalizeWorkerDeviceType } from './worker-auth-policy.js' + +test('worker device types use separate PC and mobile buckets', () => { + assert.equal(normalizeWorkerDeviceType('pc'), 'pc') + assert.equal(normalizeWorkerDeviceType('desktop'), 'pc') + assert.equal(normalizeWorkerDeviceType('mobile'), 'mobile') + assert.equal(normalizeWorkerDeviceType('tablet'), 'mobile') +}) diff --git a/apps/backend/src/services/worker-platform/worker-auth-policy.ts b/apps/backend/src/services/worker-platform/worker-auth-policy.ts index 2698a7b4..11444cf5 100644 --- a/apps/backend/src/services/worker-platform/worker-auth-policy.ts +++ b/apps/backend/src/services/worker-platform/worker-auth-policy.ts @@ -28,7 +28,7 @@ export function normalizeWorkerDeviceType(value: unknown): string { const type = String(value || '') .trim() .toLowerCase() - return type === 'mobile' || type === 'tablet' ? type : 'pc' + return type === 'mobile' || type === 'tablet' ? 'mobile' : 'pc' } export function normalizeWorkerDeviceId(value: unknown, fallback: string): string { diff --git a/apps/backend/src/services/worker-platform/worker-login-service.ts b/apps/backend/src/services/worker-platform/worker-login-service.ts index c6de2243..36ec08fd 100644 --- a/apps/backend/src/services/worker-platform/worker-login-service.ts +++ b/apps/backend/src/services/worker-platform/worker-login-service.ts @@ -141,10 +141,14 @@ async function createWorkerLoginSession( maxDevices: WORKER_MAX_DEVICES, }) if (!sessionRecord.created) { - throw createHttpError('在线设备已达到 3 台,请先删除旧的登录设备', { - statusCode: 409, - errorCode: 'worker_device_limit', - }) + const deviceLabel = deviceType === 'pc' ? 'PC' : '手机' + throw createHttpError( + `${deviceLabel}设备已达到 ${WORKER_MAX_DEVICES} 台,请先删除旧的登录设备`, + { + statusCode: 409, + errorCode: 'worker_device_limit', + }, + ) } return { ...session, passwordWeak } } diff --git a/apps/backend/src/services/worker-platform/worker-session-auth-service.ts b/apps/backend/src/services/worker-platform/worker-session-auth-service.ts index f7e23119..7e69cbe1 100644 --- a/apps/backend/src/services/worker-platform/worker-session-auth-service.ts +++ b/apps/backend/src/services/worker-platform/worker-session-auth-service.ts @@ -135,8 +135,18 @@ export async function verifyWorkerSessionToken(token: unknown): Promise { + const deviceType = item.device_type === 'mobile' ? 'mobile' : 'pc' + counts[deviceType] += 1 + return counts + }, + { pc: 0, mobile: 0 }, + ) return { maxDevices: WORKER_MAX_DEVICES, + maxDevicesByType: { pc: WORKER_MAX_DEVICES, mobile: WORKER_MAX_DEVICES }, + deviceCounts, items: sessions.map((item) => mapWorkerSession(item, session.sessionId)), } } diff --git a/apps/frontend/src/pages/worker/WorkerLoginPage.tsx b/apps/frontend/src/pages/worker/WorkerLoginPage.tsx index e525a32c..c9daceac 100644 --- a/apps/frontend/src/pages/worker/WorkerLoginPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerLoginPage.tsx @@ -662,7 +662,7 @@ export default function WorkerLoginPage() { void devices: WorkerSessionDevice[] maxDevices: number + deviceCounts: { pc: number; mobile: number } devicesLoading: boolean removeWorkerDevice: (device: WorkerSessionDevice) => Promise passwordOpen: boolean @@ -88,7 +90,7 @@ export function WorkerProfileAccountModals({ size="small" diff --git a/apps/frontend/src/pages/worker/WorkerProfilePage.tsx b/apps/frontend/src/pages/worker/WorkerProfilePage.tsx index 5641b936..67231aa4 100644 --- a/apps/frontend/src/pages/worker/WorkerProfilePage.tsx +++ b/apps/frontend/src/pages/worker/WorkerProfilePage.tsx @@ -658,6 +658,7 @@ export default function WorkerProfilePage() { setDevicesOpen={setDevicesOpen} devices={devicesQuery.data?.data.items || []} maxDevices={devicesQuery.data?.data.maxDevices || 3} + deviceCounts={devicesQuery.data?.data.deviceCounts || { pc: 0, mobile: 0 }} devicesLoading={devicesQuery.isLoading || devicesQuery.isFetching} removeWorkerDevice={removeWorkerDevice} passwordOpen={passwordOpen} diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 162ab6d1..1197a9ac 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -94,7 +94,7 @@ export function loginWorkerBySmsCode(payload: { phone: string; code: string }) { } export function fetchWorkerSessions() { - return apiGet<{ maxDevices: number; items: WorkerSessionDevice[] }>('/api/v1/worker/auth/devices') + return apiGet('/api/v1/worker/auth/devices') } export function deleteWorkerSession(sessionId: string) { @@ -102,10 +102,14 @@ export function deleteWorkerSession(sessionId: string) { } export function manageWorkerSessions(payload: { username: string; password: string }) { - return apiPost<{ maxDevices: number; items: WorkerSessionDevice[] }>( - '/api/v1/worker/auth/devices/manage/list', - payload, - ) + return apiPost('/api/v1/worker/auth/devices/manage/list', payload) +} + +export type WorkerDeviceLimitsResponse = { + maxDevices: number + maxDevicesByType: { pc: number; mobile: number } + deviceCounts: { pc: number; mobile: number } + items: WorkerSessionDevice[] } export function deleteWorkerSessionWithCredentials(payload: { diff --git a/device-limit-artifacts/DIFF_FILE.patch b/device-limit-artifacts/DIFF_FILE.patch new file mode 100644 index 00000000..dc0c67a0 --- /dev/null +++ b/device-limit-artifacts/DIFF_FILE.patch @@ -0,0 +1,229 @@ +diff --git a/apps/backend/scripts/check-sql-guard.ts b/apps/backend/scripts/check-sql-guard.ts +index 859fcb76..504bbb1c 100644 +--- a/apps/backend/scripts/check-sql-guard.ts ++++ b/apps/backend/scripts/check-sql-guard.ts +@@ -4,6 +4,7 @@ + */ + import fs from 'node:fs' + import path from 'node:path' ++import { execFileSync } from 'node:child_process' + import { fileURLToPath } from 'node:url' + + const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) +@@ -57,6 +58,24 @@ const checks: Array<{ name: string; file: string; patterns: string[] }> = [ + ] + + const failures: string[] = [] ++ ++// 已应用的历史迁移必须保持字节不变,所有结构或数据修复都必须新增迁移文件。 ++try { ++ const modifiedMigrations = execFileSync( ++ 'git', ++ ['diff', '--name-only', '--diff-filter=M', 'HEAD', '--', 'src/db/migrations'], ++ { cwd: BACKEND_ROOT, encoding: 'utf8' }, ++ ) ++ .split('\n') ++ .map((file) => file.trim()) ++ .filter((file) => file.endsWith('.sql')) ++ if (modifiedMigrations.length > 0) { ++ failures.push(`历史迁移文件不可修改: ${modifiedMigrations.join(', ')}`) ++ } ++} catch (error) { ++ failures.push(`历史迁移完整性检查失败: ${error instanceof Error ? error.message : String(error)}`) ++} ++ + for (const check of checks) { + if (!fs.existsSync(check.file)) { + failures.push(`${check.name}: 文件不存在 ${check.file}`) +diff --git a/apps/backend/src/db/migrations/069_worker_session_device_type.sql b/apps/backend/src/db/migrations/069_worker_session_device_type.sql +new file mode 100644 +index 00000000..033b921e +--- /dev/null ++++ b/apps/backend/src/db/migrations/069_worker_session_device_type.sql +@@ -0,0 +1,8 @@ ++-- 将历史平板会话归入手机设备桶,确保新旧数据使用同一套 PC/手机上限。 ++UPDATE worker_sessions ++SET device_type = 'mobile', updated_at = NOW() ++WHERE device_type = 'tablet'; ++ ++UPDATE worker_sessions ++SET device_type = 'pc', updated_at = NOW() ++WHERE device_type IS NULL OR device_type NOT IN ('pc', 'mobile'); +diff --git a/apps/backend/src/repositories/worker-platform/worker-session-repo.ts b/apps/backend/src/repositories/worker-platform/worker-session-repo.ts +index c59eb71d..fa1eaf1b 100644 +--- a/apps/backend/src/repositories/worker-platform/worker-session-repo.ts ++++ b/apps/backend/src/repositories/worker-platform/worker-session-repo.ts +@@ -41,9 +41,9 @@ export async function createWorkerSessionRecord( + ) + const activeResult = await client.query( + `SELECT * FROM worker_sessions +- WHERE worker_id = $1 AND status = 'active' AND expires_at > $2 ++ WHERE worker_id = $1 AND device_type = $2 AND status = 'active' AND expires_at > $3 + ORDER BY last_seen_at DESC, id DESC`, +- [input.workerId, input.now], ++ [input.workerId, input.deviceType, input.now], + ) + const activeSessions = activeResult.rows + if (activeSessions.length >= input.maxDevices) { +diff --git a/apps/backend/src/services/worker-platform/worker-auth-policy.test.ts b/apps/backend/src/services/worker-platform/worker-auth-policy.test.ts +new file mode 100644 +index 00000000..2da1ff5e +--- /dev/null ++++ b/apps/backend/src/services/worker-platform/worker-auth-policy.test.ts +@@ -0,0 +1,11 @@ ++import assert from 'node:assert/strict' ++import test from 'node:test' ++ ++import { normalizeWorkerDeviceType } from './worker-auth-policy.js' ++ ++test('worker device types use separate PC and mobile buckets', () => { ++ assert.equal(normalizeWorkerDeviceType('pc'), 'pc') ++ assert.equal(normalizeWorkerDeviceType('desktop'), 'pc') ++ assert.equal(normalizeWorkerDeviceType('mobile'), 'mobile') ++ assert.equal(normalizeWorkerDeviceType('tablet'), 'mobile') ++}) +diff --git a/apps/backend/src/services/worker-platform/worker-auth-policy.ts b/apps/backend/src/services/worker-platform/worker-auth-policy.ts +index 2698a7b4..11444cf5 100644 +--- a/apps/backend/src/services/worker-platform/worker-auth-policy.ts ++++ b/apps/backend/src/services/worker-platform/worker-auth-policy.ts +@@ -28,7 +28,7 @@ export function normalizeWorkerDeviceType(value: unknown): string { + const type = String(value || '') + .trim() + .toLowerCase() +- return type === 'mobile' || type === 'tablet' ? type : 'pc' ++ return type === 'mobile' || type === 'tablet' ? 'mobile' : 'pc' + } + + export function normalizeWorkerDeviceId(value: unknown, fallback: string): string { +diff --git a/apps/backend/src/services/worker-platform/worker-login-service.ts b/apps/backend/src/services/worker-platform/worker-login-service.ts +index c6de2243..36ec08fd 100644 +--- a/apps/backend/src/services/worker-platform/worker-login-service.ts ++++ b/apps/backend/src/services/worker-platform/worker-login-service.ts +@@ -141,10 +141,14 @@ async function createWorkerLoginSession( + maxDevices: WORKER_MAX_DEVICES, + }) + if (!sessionRecord.created) { +- throw createHttpError('在线设备已达到 3 台,请先删除旧的登录设备', { +- statusCode: 409, +- errorCode: 'worker_device_limit', +- }) ++ const deviceLabel = deviceType === 'pc' ? 'PC' : '手机' ++ throw createHttpError( ++ `${deviceLabel}设备已达到 ${WORKER_MAX_DEVICES} 台,请先删除旧的登录设备`, ++ { ++ statusCode: 409, ++ errorCode: 'worker_device_limit', ++ }, ++ ) + } + return { ...session, passwordWeak } + } +diff --git a/apps/backend/src/services/worker-platform/worker-session-auth-service.ts b/apps/backend/src/services/worker-platform/worker-session-auth-service.ts +index f7e23119..7e69cbe1 100644 +--- a/apps/backend/src/services/worker-platform/worker-session-auth-service.ts ++++ b/apps/backend/src/services/worker-platform/worker-session-auth-service.ts +@@ -135,8 +135,18 @@ export async function verifyWorkerSessionToken(token: unknown): Promise { ++ const deviceType = item.device_type === 'mobile' ? 'mobile' : 'pc' ++ counts[deviceType] += 1 ++ return counts ++ }, ++ { pc: 0, mobile: 0 }, ++ ) + return { + maxDevices: WORKER_MAX_DEVICES, ++ maxDevicesByType: { pc: WORKER_MAX_DEVICES, mobile: WORKER_MAX_DEVICES }, ++ deviceCounts, + items: sessions.map((item) => mapWorkerSession(item, session.sessionId)), + } + } +diff --git a/apps/frontend/src/pages/worker/WorkerLoginPage.tsx b/apps/frontend/src/pages/worker/WorkerLoginPage.tsx +index e525a32c..c9daceac 100644 +--- a/apps/frontend/src/pages/worker/WorkerLoginPage.tsx ++++ b/apps/frontend/src/pages/worker/WorkerLoginPage.tsx +@@ -662,7 +662,7 @@ export default function WorkerLoginPage() { + +
void + devices: WorkerSessionDevice[] + maxDevices: number ++ deviceCounts: { pc: number; mobile: number } + devicesLoading: boolean + removeWorkerDevice: (device: WorkerSessionDevice) => Promise + passwordOpen: boolean +@@ -88,7 +90,7 @@ export function WorkerProfileAccountModals({ + + + size="small" +diff --git a/apps/frontend/src/pages/worker/WorkerProfilePage.tsx b/apps/frontend/src/pages/worker/WorkerProfilePage.tsx +index 5641b936..67231aa4 100644 +--- a/apps/frontend/src/pages/worker/WorkerProfilePage.tsx ++++ b/apps/frontend/src/pages/worker/WorkerProfilePage.tsx +@@ -658,6 +658,7 @@ export default function WorkerProfilePage() { + setDevicesOpen={setDevicesOpen} + devices={devicesQuery.data?.data.items || []} + maxDevices={devicesQuery.data?.data.maxDevices || 3} ++ deviceCounts={devicesQuery.data?.data.deviceCounts || { pc: 0, mobile: 0 }} + devicesLoading={devicesQuery.isLoading || devicesQuery.isFetching} + removeWorkerDevice={removeWorkerDevice} + passwordOpen={passwordOpen} +diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts +index 162ab6d1..1197a9ac 100644 +--- a/apps/frontend/src/services/worker.ts ++++ b/apps/frontend/src/services/worker.ts +@@ -94,7 +94,7 @@ export function loginWorkerBySmsCode(payload: { phone: string; code: string }) { + } + + export function fetchWorkerSessions() { +- return apiGet<{ maxDevices: number; items: WorkerSessionDevice[] }>('/api/v1/worker/auth/devices') ++ return apiGet('/api/v1/worker/auth/devices') + } + + export function deleteWorkerSession(sessionId: string) { +@@ -102,10 +102,14 @@ export function deleteWorkerSession(sessionId: string) { + } + + export function manageWorkerSessions(payload: { username: string; password: string }) { +- return apiPost<{ maxDevices: number; items: WorkerSessionDevice[] }>( +- '/api/v1/worker/auth/devices/manage/list', +- payload, +- ) ++ return apiPost('/api/v1/worker/auth/devices/manage/list', payload) ++} ++ ++export type WorkerDeviceLimitsResponse = { ++ maxDevices: number ++ maxDevicesByType: { pc: number; mobile: number } ++ deviceCounts: { pc: number; mobile: number } ++ items: WorkerSessionDevice[] + } + + export function deleteWorkerSessionWithCredentials(payload: { diff --git a/device-limit-artifacts/MODIFIED_FILE.ts b/device-limit-artifacts/MODIFIED_FILE.ts new file mode 100644 index 00000000..fa1eaf1b --- /dev/null +++ b/device-limit-artifacts/MODIFIED_FILE.ts @@ -0,0 +1,140 @@ +import { query, withTransaction } from '../../db/client.js' +import type { WorkerSessionRow } from './types.js' + +export type CreateWorkerSessionInput = { + sessionId: string + workerId: number + deviceId: string + deviceType: string + deviceName: string + userAgent: string + ipAddress: string + issuedAt: string + expiresAt: string + now: string + maxDevices: number +} + +export type CreateWorkerSessionResult = { + created: boolean + activeSessions: WorkerSessionRow[] + session: WorkerSessionRow | null +} + +export async function createWorkerSessionRecord( + input: CreateWorkerSessionInput, +): Promise { + return withTransaction(async (client) => { + // 对账号加行锁,避免并发登录同时绕过设备数量限制。 + await client.query('SELECT id FROM worker_users WHERE id = $1 FOR UPDATE', [input.workerId]) + await client.query( + `UPDATE worker_sessions + SET status = 'revoked', revoked_at = $2, updated_at = $2 + WHERE worker_id = $1 AND status = 'active' AND expires_at <= $2`, + [input.workerId, input.now], + ) + await client.query( + `UPDATE worker_sessions + SET status = 'revoked', revoked_at = $3, updated_at = $3 + WHERE worker_id = $1 AND device_id = $2 AND status = 'active'`, + [input.workerId, input.deviceId, input.now], + ) + const activeResult = await client.query( + `SELECT * FROM worker_sessions + WHERE worker_id = $1 AND device_type = $2 AND status = 'active' AND expires_at > $3 + ORDER BY last_seen_at DESC, id DESC`, + [input.workerId, input.deviceType, input.now], + ) + const activeSessions = activeResult.rows + if (activeSessions.length >= input.maxDevices) { + return { created: false, activeSessions, session: null } + } + const result = await client.query( + `INSERT INTO worker_sessions ( + session_id, worker_id, device_id, device_type, device_name, + user_agent, ip_address, status, issued_at, last_seen_at, + expires_at, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $8, $9, $10, $10) + RETURNING *`, + [ + input.sessionId, + input.workerId, + input.deviceId, + input.deviceType, + input.deviceName, + input.userAgent, + input.ipAddress, + input.issuedAt, + input.expiresAt, + input.now, + ], + ) + return { created: true, activeSessions, session: result.rows[0] || null } + }) +} + +export async function getWorkerSessionBySessionId( + sessionId: string, +): Promise { + const result = await query( + 'SELECT * FROM worker_sessions WHERE session_id = $1 LIMIT 1', + [String(sessionId || '').trim()], + ) + return result.rows[0] || null +} + +export async function touchWorkerSession(sessionId: string, now: string): Promise { + const result = await query( + `UPDATE worker_sessions + SET last_seen_at = $2, updated_at = $2 + WHERE session_id = $1 AND status = 'active' AND expires_at > $2`, + [String(sessionId || '').trim(), now], + ) + return (result.rowCount || 0) > 0 +} + +export async function revokeWorkerSession( + workerId: number | string, + sessionId: string, + now: string, +): Promise { + const result = await query( + `UPDATE worker_sessions + SET status = 'revoked', revoked_at = $3, updated_at = $3 + WHERE worker_id = $1 AND session_id = $2 AND status = 'active'`, + [Number(workerId), String(sessionId || '').trim(), now], + ) + return (result.rowCount || 0) > 0 +} + +export async function revokeAllWorkerSessions( + workerId: number | string, + now: string, +): Promise { + const result = await query( + `UPDATE worker_sessions + SET status = 'revoked', revoked_at = $2, updated_at = $2 + WHERE worker_id = $1 AND status = 'active'`, + [Number(workerId), now], + ) + return result.rowCount || 0 +} + +export async function listWorkerSessions( + workerId: number | string, + now: string, +): Promise { + await query( + `UPDATE worker_sessions + SET status = 'revoked', revoked_at = $2, updated_at = $2 + WHERE worker_id = $1 AND status = 'active' AND expires_at <= $2`, + [Number(workerId), now], + ) + const result = await query( + `SELECT * FROM worker_sessions + WHERE worker_id = $1 AND status = 'active' AND expires_at > $2 + ORDER BY device_type ASC, last_seen_at DESC, id DESC`, + [Number(workerId), now], + ) + return result.rows +} diff --git a/device-limit-artifacts/ROLLBACK.sh b/device-limit-artifacts/ROLLBACK.sh new file mode 100755 index 00000000..8bd02576 --- /dev/null +++ b/device-limit-artifacts/ROLLBACK.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${1:?usage: ROLLBACK.sh }" +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + +while IFS= read -r relative_path; do + mkdir -p "$ROOT/$(dirname "$relative_path")" + cp "$SCRIPT_DIR/baseline-copy/$relative_path" "$ROOT/$relative_path" +done <<'PATHS' +apps/backend/src/db/migrations/029_worker_sessions.sql +apps/backend/src/repositories/worker-platform/worker-session-repo.ts +apps/backend/src/services/worker-platform/worker-auth-policy.ts +apps/backend/src/services/worker-platform/worker-login-service.ts +apps/backend/src/services/worker-platform/worker-session-auth-service.ts +apps/frontend/src/pages/worker/WorkerLoginPage.tsx +apps/frontend/src/pages/worker/WorkerProfileAccountModals.tsx +apps/frontend/src/pages/worker/WorkerProfilePage.tsx +apps/frontend/src/services/worker.ts +PATHS + +rm -f "$ROOT/apps/backend/src/db/migrations/069_worker_session_device_type.sql" +rm -f "$ROOT/apps/backend/src/services/worker-platform/worker-auth-policy.test.ts" +printf 'restored device-limit baseline in %s\n' "$ROOT" diff --git a/device-limit-artifacts/VERIFICATION.txt b/device-limit-artifacts/VERIFICATION.txt new file mode 100644 index 00000000..552aae8c --- /dev/null +++ b/device-limit-artifacts/VERIFICATION.txt @@ -0,0 +1,64 @@ +Worker login device limit verification + +Changed branch/fields: +- worker_sessions device_type bucket: pc and mobile each allow at most 3 active devices +- tablet input and historical tablet rows normalize to mobile +- SQL guard rejects modifications to tracked historical migration files +- worker device APIs expose maxDevicesByType and deviceCounts +- worker login/device-management UI states separate PC and phone quotas + +Artifacts: +- MODIFIED_FILE: /Users/yml/codes/order_site/device-limit-artifacts/MODIFIED_FILE.ts +- DIFF_FILE: /Users/yml/codes/order_site/device-limit-artifacts/DIFF_FILE.patch +- VERIFICATION.txt: /Users/yml/codes/order_site/device-limit-artifacts/VERIFICATION.txt +- ROLLBACK.sh: /Users/yml/codes/order_site/device-limit-artifacts/ROLLBACK.sh + +Source paths changed: +- /Users/yml/codes/order_site/apps/backend/src/repositories/worker-platform/worker-session-repo.ts +- /Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-auth-policy.ts +- /Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-login-service.ts +- /Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-session-auth-service.ts +- /Users/yml/codes/order_site/apps/backend/src/db/migrations/069_worker_session_device_type.sql +- /Users/yml/codes/order_site/apps/backend/scripts/check-sql-guard.ts +- /Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-auth-policy.test.ts +- /Users/yml/codes/order_site/apps/frontend/src/services/worker.ts +- /Users/yml/codes/order_site/apps/frontend/src/pages/worker/WorkerLoginPage.tsx +- /Users/yml/codes/order_site/apps/frontend/src/pages/worker/WorkerProfileAccountModals.tsx +- /Users/yml/codes/order_site/apps/frontend/src/pages/worker/WorkerProfilePage.tsx + +BASELINE +Command: npm test +Working directory: /tmp/order_site_device_baseline_0830/apps/backend +Input: detached HEAD ffb71f22 source before device-limit changes +Literal result: tests 378; pass 376; fail 0; skipped 2; exit status 0 + +Command: npm test +Working directory: /tmp/order_site_device_baseline_0830/apps/frontend +Input: detached HEAD ffb71f22 source before device-limit changes +Literal result: tests 15; pass 15; fail 0; skipped 0; exit status 0 + +Baseline worker-session-repo SHA-256: 9136d67c8f5cb3641979f8e5b577d9f4bd44f0b399862d2feaa69f2002d19ef1 + +MODIFIED +Command: npm run check:sql +Working directory: /Users/yml/codes/order_site/apps/backend +Input: current tree with immutable historical migration guard enabled +Literal result: [sql-guard] SQL 约束检查通过; exit status 0 + +Command: npm run format:check && npm run lint:check && npm run typecheck && npm test && npm run build +Working directory: /Users/yml/codes/order_site/apps/backend +Input: modified per-device session quota implementation and migration +Literal result: format passed; lint passed; typecheck passed; tests 379; pass 377; fail 0; skipped 2; build completed; exit status 0 + +Command: npm run format:check && npm run lint:check && npm run typecheck && npm test && npm run build +Working directory: /Users/yml/codes/order_site/apps/frontend +Input: modified per-device quota API types and management UI +Literal result: format passed; lint passed; typecheck passed; tests 15; pass 15; fail 0; skipped 0; build completed; exit status 0 + +Modified MODIFIED_FILE SHA-256: 33147b25bce759bc1af079f495d347196fda003e74f280dd41e1a194c75ff443 + +ROLLBACK +Command: device-limit-artifacts/ROLLBACK.sh device-limit-artifacts/rollback-copy-0830 +Input: independent copy containing modified backend/frontend files, migration, and regression test +Literal result: restored device-limit baseline in device-limit-artifacts/rollback-copy-0830; worker-session-repo SHA-256 restored to 9136d67c8f5cb3641979f8e5b577d9f4bd44f0b399862d2feaa69f2002d19ef1; new migration and regression test removed; exit status 0 +Restored behavior/status: rollback copy uses the original aggregate device-limit query and UI wording; working source and MODIFIED_FILE.ts remain changed.