artifacts文件夹
代码质量 / backend (push) Canceled after 0s
代码质量 / frontend (push) Canceled after 0s

This commit is contained in:
yml2213
2026-08-31 11:49:51 +08:00
parent b832279c1f
commit 298dcdc3eb
5 changed files with 2 additions and 457 deletions
+2
View File
@@ -36,3 +36,5 @@ send_code/
# 服务器本地备份暂存目录(deploy/backup-db.sh)
/backups/
artifacts/
-229
View File
@@ -1,229 +0,0 @@
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<WorkerSessionRow>(
`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<WorkerSe
export async function getWorkerSessions(session: WorkerSession) {
const sessions = await listWorkerSessions(session.workerId, nowIso())
+ const deviceCounts = sessions.reduce(
+ (counts, item) => {
+ 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() {
<Alert
showIcon
type="info"
- message="验证账号密码后,可删除旧设备。所有 PC 和手机合计最多同时在线 3 台。"
+ message="验证账号密码后,可删除旧设备。PC 和手机分别最多同时在线 3 台。"
style={{ marginBottom: 16 }}
/>
<Table
diff --git a/apps/frontend/src/pages/worker/WorkerProfileAccountModals.tsx b/apps/frontend/src/pages/worker/WorkerProfileAccountModals.tsx
index e4330ba8..af9492ab 100644
--- a/apps/frontend/src/pages/worker/WorkerProfileAccountModals.tsx
+++ b/apps/frontend/src/pages/worker/WorkerProfileAccountModals.tsx
@@ -38,6 +38,7 @@ export function WorkerProfileAccountModals({
setDevicesOpen,
devices,
maxDevices,
+ deviceCounts,
devicesLoading,
removeWorkerDevice,
passwordOpen,
@@ -60,6 +61,7 @@ export function WorkerProfileAccountModals({
setDevicesOpen: (open: boolean) => void
devices: WorkerSessionDevice[]
maxDevices: number
+ deviceCounts: { pc: number; mobile: number }
devicesLoading: boolean
removeWorkerDevice: (device: WorkerSessionDevice) => Promise<void>
passwordOpen: boolean
@@ -88,7 +90,7 @@ export function WorkerProfileAccountModals({
<Alert
showIcon
type="info"
- message={`所有设备合计最多同时在线 ${maxDevices} 台,删除旧设备后才能在新设备登录。`}
+ message={`PC 和手机分别最多同时在线 ${maxDevices} 台(当前 PC ${deviceCounts.pc} 台、手机 ${deviceCounts.mobile} 台),删除对应旧设备后才能登录。`}
/>
<Table<WorkerSessionDevice>
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<WorkerDeviceLimitsResponse>('/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<WorkerDeviceLimitsResponse>('/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: {
-140
View File
@@ -1,140 +0,0 @@
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<CreateWorkerSessionResult> {
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<WorkerSessionRow>(
`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<WorkerSessionRow>(
`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<WorkerSessionRow | null> {
const result = await query<WorkerSessionRow>(
'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<boolean> {
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<boolean> {
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<number> {
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<WorkerSessionRow[]> {
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<WorkerSessionRow>(
`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
}
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${1:?usage: ROLLBACK.sh <workspace-copy>}"
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"
-64
View File
@@ -1,64 +0,0 @@
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.