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: {