diff --git a/furniture-3d-system/agents/script-monitor/README.md b/furniture-3d-system/agents/script-monitor/README.md index 5ae5ebaf..8d4ee1dc 100644 --- a/furniture-3d-system/agents/script-monitor/README.md +++ b/furniture-3d-system/agents/script-monitor/README.md @@ -8,6 +8,11 @@ 2. 复制本目录到目标机器,把 `config.example.json` 复制为 `config.json`,填入凭据、内外网地址和脚本任务说明。 3. 先运行 `node agent.mjs` 确认上报,再使用 `pm2 start ecosystem.config.cjs` 常驻。 +需要在平台点击“立即运行”的脚本,必须在 `config.json` 对应任务中显式配置 +`"allowManualRun": true`。平台只会下发脚本 `key`,Agent 始终执行本机配置里预存的 +`command`,不会接收平台传来的任意命令。Agent 默认每 5 秒主动领取一次执行请求, +因此内网机器不需要开放入站端口。 + `connectionMode` 支持: - `intranet`:只走内网地址。 diff --git a/furniture-3d-system/agents/script-monitor/agent.mjs b/furniture-3d-system/agents/script-monitor/agent.mjs index e40c0116..e18dc953 100644 --- a/furniture-3d-system/agents/script-monitor/agent.mjs +++ b/furniture-3d-system/agents/script-monitor/agent.mjs @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { execFileSync } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import { existsSync, readFileSync, statSync } from 'node:fs' import { hostname, platform, release } from 'node:os' import { dirname, resolve } from 'node:path' @@ -26,6 +26,7 @@ export function loadConfig(configPath = process.env.SCRIPT_MONITOR_CONFIG || res } config.reportIntervalSec = Math.max(15, Number(config.reportIntervalSec) || 60) config.requestTimeoutMs = Math.max(1000, Number(config.requestTimeoutMs) || 10000) + config.commandPollIntervalSec = Math.min(60, Math.max(2, Number(config.commandPollIntervalSec) || 5)) config.scripts = Array.isArray(config.scripts) ? config.scripts : [] return config } @@ -120,8 +121,9 @@ function scanPm2() { schedule: null, commandPreview: redactCommand(processInfo.pm2_env?.pm_exec_path || ''), expectedIntervalSec: null, - status, - isEnabled: status !== 'DISABLED', + status, + isEnabled: status !== 'DISABLED', + canManualRun: false, lastStartedAt: processInfo.pm2_env?.pm_uptime ? new Date(processInfo.pm2_env.pm_uptime).toISOString() : null, @@ -152,6 +154,7 @@ function parseCronLine(line) { expectedIntervalSec: null, status: 'UNKNOWN', isEnabled: true, + canManualRun: false, } } @@ -229,6 +232,7 @@ function normalizeConfiguredScript(script) { expectedIntervalSec: script.expectedIntervalSec ? Number(script.expectedIntervalSec) : null, status: result.status, isEnabled: script.enabled !== false, + canManualRun: script.allowManualRun === true && Boolean(script.command), lastResultSummary: result.summary, } } @@ -260,6 +264,110 @@ export async function reportSnapshot(config) { console.log(`[script-monitor] 已通过${result.endpoint.kind === 'INTRANET' ? '内网' : '公网'}上报 ${scripts.length} 个脚本`) } +function keepTail(current, chunk, limit = 12000) { + const next = current + chunk.toString() + return next.length > limit ? next.slice(-limit) : next +} + +async function reportManualRun(config, command, input) { + return postToCenter(config, '/api/internal/script-monitor/run', { + commandId: command.id, + scriptKey: command.scriptKey, + scriptName: command.scriptName, + taskDescription: command.taskDescription || '', + jobType: String(command.jobType || 'OTHER').toUpperCase(), + ...input, + }) +} + +export async function executeManualCommand(config, command) { + const script = config.scripts.find((item) => item.key === command.scriptKey) + const startedAt = new Date() + const externalRunId = `manual:${command.id}` + if (!script?.command || script.allowManualRun !== true) { + const message = 'Agent 配置未开放该脚本的手动运行权限' + await reportManualRun(config, command, { + externalRunId, + status: 'FAILED', + trigger: 'manual', + startedAt: startedAt.toISOString(), + finishedAt: new Date().toISOString(), + durationMs: 0, + exitCode: 1, + resultSummary: message, + errorTail: message, + }) + return + } + + await reportManualRun(config, command, { + externalRunId, + status: 'RUNNING', + trigger: 'manual', + startedAt: startedAt.toISOString(), + }) + let outputTail = '' + let errorTail = '' + try { + const child = spawn(script.command, { + cwd: script.cwd ? resolve(script.cwd) : process.cwd(), + env: process.env, + shell: true, + stdio: ['ignore', 'pipe', 'pipe'], + }) + child.stdout.on('data', (chunk) => { + outputTail = keepTail(outputTail, chunk) + process.stdout.write(chunk) + }) + child.stderr.on('data', (chunk) => { + errorTail = keepTail(errorTail, chunk) + process.stderr.write(chunk) + }) + const exitCode = await new Promise((resolveExit, reject) => { + child.once('error', reject) + child.once('close', (code) => resolveExit(code ?? 1)) + }) + const finishedAt = new Date() + await reportManualRun(config, command, { + externalRunId, + status: exitCode === 0 ? 'SUCCESS' : 'FAILED', + trigger: 'manual', + startedAt: startedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + durationMs: finishedAt.getTime() - startedAt.getTime(), + exitCode, + resultSummary: exitCode === 0 ? '手动运行成功' : `手动运行失败,退出码 ${exitCode}`, + outputTail, + errorTail, + }) + } catch (error) { + const finishedAt = new Date() + await reportManualRun(config, command, { + externalRunId, + status: 'FAILED', + trigger: 'manual', + startedAt: startedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + durationMs: finishedAt.getTime() - startedAt.getTime(), + exitCode: 1, + resultSummary: `手动运行启动失败:${error.message}`, + errorTail: error.message, + }) + } +} + +export async function pollManualCommand(config) { + const result = await postToCenter( + config, + '/api/internal/script-monitor/commands/claim', + {}, + ) + const command = result.body?.data?.command + if (!command) return false + await executeManualCommand(config, command) + return true +} + async function main() { const config = loadConfig() const run = async () => { @@ -269,10 +377,25 @@ async function main() { console.error('[script-monitor] 上报失败:', error.message) } } + let commandBusy = false + const poll = async () => { + if (commandBusy) return + commandBusy = true + try { + await pollManualCommand(config) + } catch (error) { + console.error('[script-monitor] 手动任务轮询失败:', error.message) + } finally { + commandBusy = false + } + } await run() + await poll() const timer = setInterval(run, config.reportIntervalSec * 1000) + const commandTimer = setInterval(poll, config.commandPollIntervalSec * 1000) const stop = () => { clearInterval(timer) + clearInterval(commandTimer) process.exit(0) } process.on('SIGINT', stop) diff --git a/furniture-3d-system/agents/script-monitor/agent.test.mjs b/furniture-3d-system/agents/script-monitor/agent.test.mjs new file mode 100644 index 00000000..9591e837 --- /dev/null +++ b/furniture-3d-system/agents/script-monitor/agent.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { executeManualCommand } from './agent.mjs' + +function config(overrides = {}) { + return { + agentId: 'agent-1', + agentToken: 'secret-token', + connectionMode: 'internet', + centerUrls: { internet: 'https://monitor.example.test' }, + requestTimeoutMs: 5000, + scripts: [{ + key: 'local-check', + name: '本机检查', + taskDescription: '验证手动触发', + jobType: 'MANUAL', + allowManualRun: true, + command: `${JSON.stringify(process.execPath)} -e "console.log('manual-ok')"`, + }], + ...overrides, + } +} + +function command() { + return { + id: 'command-1', + scriptKey: 'local-check', + scriptName: '本机检查', + taskDescription: '验证手动触发', + jobType: 'MANUAL', + } +} + +test('manual command executes only the local configured command and reports its result', async () => { + const previousFetch = globalThis.fetch + const reports = [] + globalThis.fetch = async (_url, init) => { + reports.push(JSON.parse(init.body)) + return new Response(JSON.stringify({ success: true, data: { ok: true } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + try { + await executeManualCommand(config(), command()) + } finally { + globalThis.fetch = previousFetch + } + + assert.equal(reports.length, 2) + assert.equal(reports[0].status, 'RUNNING') + assert.equal(reports[1].status, 'SUCCESS') + assert.equal(reports[1].exitCode, 0) + assert.match(reports[1].outputTail, /manual-ok/) + assert.equal(reports[1].commandId, 'command-1') +}) + +test('manual command is rejected when allowManualRun is not explicitly enabled', async () => { + const previousFetch = globalThis.fetch + const reports = [] + globalThis.fetch = async (_url, init) => { + reports.push(JSON.parse(init.body)) + return new Response(JSON.stringify({ success: true, data: { ok: true } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + const blockedConfig = config() + blockedConfig.scripts[0].allowManualRun = false + try { + await executeManualCommand(blockedConfig, command()) + } finally { + globalThis.fetch = previousFetch + } + + assert.equal(reports.length, 1) + assert.equal(reports[0].status, 'FAILED') + assert.match(reports[0].resultSummary, /未开放/) +}) diff --git a/furniture-3d-system/agents/script-monitor/config.example.json b/furniture-3d-system/agents/script-monitor/config.example.json index 63cfcc50..f55c0822 100644 --- a/furniture-3d-system/agents/script-monitor/config.example.json +++ b/furniture-3d-system/agents/script-monitor/config.example.json @@ -8,6 +8,7 @@ "internet": "https://cp.jiansun.vip" }, "reportIntervalSec": 60, + "commandPollIntervalSec": 5, "requestTimeoutMs": 10000, "autoDiscover": { "cron": true, @@ -21,6 +22,7 @@ "jobType": "CRON", "schedule": "*/15 * * * *", "expectedIntervalSec": 900, + "allowManualRun": true, "command": "npm run sync:supply-chain-product-archive", "cwd": "/opt/furniture-3d-system-prod/current", "resultCheck": { diff --git a/furniture-3d-system/prisma/migrations/20260828170000_add_script_monitor_manual_commands/migration.sql b/furniture-3d-system/prisma/migrations/20260828170000_add_script_monitor_manual_commands/migration.sql new file mode 100644 index 00000000..72208e72 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260828170000_add_script_monitor_manual_commands/migration.sql @@ -0,0 +1,32 @@ +-- CreateEnum +CREATE TYPE "ScriptMonitorCommandStatus" AS ENUM ('PENDING', 'RUNNING', 'SUCCESS', 'FAILED', 'EXPIRED'); + +-- AlterTable +ALTER TABLE "ScriptMonitorScript" +ADD COLUMN "canManualRun" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "ScriptMonitorCommand" ( + "id" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "scriptId" TEXT NOT NULL, + "status" "ScriptMonitorCommandStatus" NOT NULL DEFAULT 'PENDING', + "requestedById" TEXT, + "requestedByName" TEXT, + "requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "claimedAt" TIMESTAMP(3), + "finishedAt" TIMESTAMP(3), + "externalRunId" TEXT, + "errorMessage" TEXT, + + CONSTRAINT "ScriptMonitorCommand_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ScriptMonitorCommand_agentId_status_requestedAt_idx" ON "ScriptMonitorCommand"("agentId", "status", "requestedAt"); +CREATE INDEX "ScriptMonitorCommand_scriptId_requestedAt_idx" ON "ScriptMonitorCommand"("scriptId", "requestedAt" DESC); + +-- AddForeignKey +ALTER TABLE "ScriptMonitorCommand" ADD CONSTRAINT "ScriptMonitorCommand_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "ScriptMonitorAgent"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ScriptMonitorCommand" ADD CONSTRAINT "ScriptMonitorCommand_scriptId_fkey" FOREIGN KEY ("scriptId") REFERENCES "ScriptMonitorScript"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 8082d745..b7eeb60b 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -3512,6 +3512,7 @@ model StoreAccount { @@index([accountType]) @@index([updatedAt]) } + model StoreAccountStoreProfile { id String @id @default(cuid()) storeName String @@ -3529,6 +3530,7 @@ model StoreAccountStoreProfile { @@index([storeName]) @@index([platform]) } + model StoreAccountChangeLog { id String @id @default(cuid()) storeAccountId String? @@ -3987,6 +3989,14 @@ enum ScriptMonitorRunStatus { FAILED } +enum ScriptMonitorCommandStatus { + PENDING + RUNNING + SUCCESS + FAILED + EXPIRED +} + model ScriptMonitorAgent { id String @id @default(cuid()) name String @@ -4004,6 +4014,7 @@ model ScriptMonitorAgent { updatedAt DateTime @updatedAt scripts ScriptMonitorScript[] runs ScriptMonitorRun[] + commands ScriptMonitorCommand[] @@index([isActive, lastSeenAt]) } @@ -4021,6 +4032,7 @@ model ScriptMonitorScript { expectedIntervalSec Int? status ScriptMonitorJobStatus @default(UNKNOWN) isEnabled Boolean @default(true) + canManualRun Boolean @default(false) lastStartedAt DateTime? lastFinishedAt DateTime? lastDurationMs Int? @@ -4032,6 +4044,7 @@ model ScriptMonitorScript { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt runs ScriptMonitorRun[] + commands ScriptMonitorCommand[] @@unique([agentId, externalKey]) @@index([agentId, status]) @@ -4060,3 +4073,23 @@ model ScriptMonitorRun { @@index([scriptId, startedAt(sort: Desc)]) @@index([agentId, startedAt(sort: Desc)]) } + +model ScriptMonitorCommand { + id String @id @default(cuid()) + agentId String + agent ScriptMonitorAgent @relation(fields: [agentId], references: [id], onDelete: Cascade) + scriptId String + script ScriptMonitorScript @relation(fields: [scriptId], references: [id], onDelete: Cascade) + status ScriptMonitorCommandStatus @default(PENDING) + requestedById String? + requestedByName String? + requestedAt DateTime @default(now()) + expiresAt DateTime + claimedAt DateTime? + finishedAt DateTime? + externalRunId String? + errorMessage String? @db.Text + + @@index([agentId, status, requestedAt]) + @@index([scriptId, requestedAt(sort: Desc)]) +} diff --git a/furniture-3d-system/src/app/api/internal/script-monitor/commands/claim/route.ts b/furniture-3d-system/src/app/api/internal/script-monitor/commands/claim/route.ts new file mode 100644 index 00000000..73ffae0e --- /dev/null +++ b/furniture-3d-system/src/app/api/internal/script-monitor/commands/claim/route.ts @@ -0,0 +1,25 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { scriptMonitorService } from '@/server/services/script-monitor.service' + +function readAgentCredentials(request: NextRequest) { + const agentId = request.headers.get('x-script-monitor-agent-id')?.trim() ?? '' + const authorization = request.headers.get('authorization') ?? '' + const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : '' + return { agentId, token } +} + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const { agentId, token } = readAgentCredentials(request) + return jsonSuccess( + await scriptMonitorService.claimCommand(agentId, token), + 200, + ctx.requestId, + ) + }, + { requireAuth: false }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/script-monitor/scripts/[id]/run/route.ts b/furniture-3d-system/src/app/api/supply-chain/script-monitor/scripts/[id]/run/route.ts new file mode 100644 index 00000000..da6d9a62 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/script-monitor/scripts/[id]/run/route.ts @@ -0,0 +1,36 @@ +import { NextRequest } from 'next/server' +import { AppError, ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { scriptMonitorService } from '@/server/services/script-monitor.service' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> }, +) { + return handleRequest( + request, + async (ctx) => { + const access = await supplyChainPermissionSettingsService.getUserAccess( + ctx.user, + 'scriptMonitor', + ) + if (!access.edit) { + throw new AppError( + ErrorCode.AUTH_PERMISSION_DENIED, + '没有手动运行脚本的权限', + ) + } + const { id } = await context.params + return jsonSuccess( + await scriptMonitorService.requestManualRun(id, { + id: ctx.user.id, + name: ctx.user.name, + }), + 202, + ctx.requestId, + ) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/bank-account/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/bank-account/route.ts index f705340f..e6b35d3f 100644 --- a/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/bank-account/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/bank-account/route.ts @@ -7,6 +7,8 @@ type RouteProps = { params: Promise<{ id: string }> } export async function GET(request: NextRequest, props: RouteProps) { return handleRequest(request, async (ctx) => { const { id } = await props.params - return jsonSuccess(await storeAccountManagementService.revealBankAccount(id, ctx.user), 200, ctx.requestId) + const response = jsonSuccess(await storeAccountManagementService.revealBankAccount(id, ctx.user), 200, ctx.requestId) + response.headers.set('Cache-Control', 'no-store') + return response }, { requireAuth: true }) } diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/route.ts index e83551cc..bb87f175 100644 --- a/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/route.ts @@ -10,9 +10,11 @@ const updateSchema = z.object({ operationDepartmentHead: z.string().trim().max(240).optional(), operationDirector: z.string().trim().max(240).optional(), profitCenter: z.string().trim().max(240).optional(), + subject: z.string().trim().max(240).optional(), accountType: z.string().trim().max(20).optional(), account: z.string().trim().max(240).optional(), password: z.string().max(500).optional(), + boundBankAccountNumber: z.string().trim().max(500).optional(), boundPhone: z.string().trim().max(80).optional(), verificationContact: z.string().trim().max(240).optional(), jstOrderService: z.string().trim().max(240).optional(), diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/import/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/import/route.ts index 986c0e98..0cdef312 100644 --- a/furniture-3d-system/src/app/api/supply-chain/store-account-management/import/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/import/route.ts @@ -6,8 +6,8 @@ import { handleRequest, jsonError } from '@/server/core/response' import { storeAccountManagementService } from '@/server/services/store-account-management.service' const HEADER_MAP: Record = { - 店铺名称: 'storeName', 店铺运营负责人: 'operationOwner', 店铺运营部门负责人: 'operationDepartmentHead', 店铺运营总监: 'operationDirector', 店铺所属利润中心: 'profitCenter', 账号类型: 'accountType', 账号: 'account', 密码: 'password', 绑定手机号: 'boundPhone', 手机号验证码对接人: 'verificationContact', 聚水潭订单服务号: 'jstOrderService', 账号分组名称: 'accountGroupName', - storeName: 'storeName', operationOwner: 'operationOwner', operationDepartmentHead: 'operationDepartmentHead', operationDirector: 'operationDirector', profitCenter: 'profitCenter', accountType: 'accountType', account: 'account', password: 'password', boundPhone: 'boundPhone', verificationContact: 'verificationContact', jstOrderService: 'jstOrderService', accountGroupName: 'accountGroupName', + 店铺名称: 'storeName', 店铺运营负责人: 'operationOwner', 店铺运营部门负责人: 'operationDepartmentHead', 店铺运营总监: 'operationDirector', 店铺所属利润中心: 'profitCenter', 主体: 'subject', 账号类型: 'accountType', 账号: 'account', 密码: 'password', 店铺绑定银行卡号: 'boundBankAccountNumber', 绑定手机号: 'boundPhone', 手机号验证码对接人: 'verificationContact', 聚水潭订单服务号: 'jstOrderService', 账号分组名称: 'accountGroupName', + storeName: 'storeName', operationOwner: 'operationOwner', operationDepartmentHead: 'operationDepartmentHead', operationDirector: 'operationDirector', profitCenter: 'profitCenter', subject: 'subject', accountType: 'accountType', account: 'account', password: 'password', boundBankAccountNumber: 'boundBankAccountNumber', boundPhone: 'boundPhone', verificationContact: 'verificationContact', jstOrderService: 'jstOrderService', accountGroupName: 'accountGroupName', } export async function POST(request: NextRequest) { diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/import/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/import/route.ts index 40922490..ec3429b5 100644 --- a/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/import/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/import/route.ts @@ -1,39 +1,58 @@ import { NextRequest } from 'next/server' import * as XLSX from 'xlsx' +import { + STORE_ACCOUNT_PROFILE_FIELDS, + STORE_ACCOUNT_PROFILE_PLATFORMS, + type StoreAccountProfilePlatform, +} from '@/lib/store-account-management' import { ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { storeAccountManagementService } from '@/server/services/store-account-management.service' -import { STORE_ACCOUNT_PROFILE_FIELDS } from '@/lib/store-account-management' -const HEADER_MAP: Record = Object.fromEntries([ +const HEADER_MAP = new Map([ ['店铺名称', 'storeName'], - ...STORE_ACCOUNT_PROFILE_FIELDS.map((field) => [field.label, field.key]), + ['storeName', 'storeName'], + ...STORE_ACCOUNT_PROFILE_FIELDS.flatMap((field) => [ + [field.label, field.key], + [field.key, field.key], + ] as Array<[string, string]>), ]) export async function POST(request: NextRequest) { return handleRequest(request, async (ctx) => { const form = await request.formData() const file = form.get('file') - const platform = String(form.get('platform') ?? '') - if (!(file instanceof File)) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId) - if (file.size > 10 * 1024 * 1024) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件不能超过10MB', 400, ctx.requestId) + const platform = String(form.get('platform') ?? '').trim().toUpperCase() + if (!(file instanceof File)) { + return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId) + } + if (!STORE_ACCOUNT_PROFILE_PLATFORMS.some((item) => item.key === platform)) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '平台只能是京东或京喜', 400, ctx.requestId) + } + if (file.size > 10 * 1024 * 1024) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件不能超过10MB', 400, ctx.requestId) + } const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer', cellDates: false }) const sheet = workbook.Sheets[workbook.SheetNames[0]] - if (!sheet) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId) - const rawRows = XLSX.utils.sheet_to_json>(sheet, { defval: '' }) - if (rawRows.length === 0) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId) - if (rawRows.length > 5000) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '单次最多导入5000行', 400, ctx.requestId) - let processed = 0 - for (const raw of rawRows) { - const mapped = Object.fromEntries(Object.entries(raw).flatMap(([header, value]) => { - const key = HEADER_MAP[header.trim()] - return key ? [[key, value]] : [] - })) - const storeName = String(mapped.storeName ?? '').trim() - if (!storeName) throw new Error(`第${processed + 2}行缺少店铺名称`) - await storeAccountManagementService.saveStoreProfile({ storeName, platform, values: mapped, user: ctx.user }) - processed += 1 + if (!sheet) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId) } - return jsonSuccess({ processed, total: rawRows.length }, 200, ctx.requestId) + const rawRows = XLSX.utils.sheet_to_json>(sheet, { defval: '' }) + const rows = rawRows.map((raw) => Object.fromEntries(Object.entries(raw).flatMap(([header, value]) => { + const key = HEADER_MAP.get(header.trim()) + return key ? [[key, value]] : [] + }))) + if (rows.length === 0) { + return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId) + } + if (rows.length > 5000) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '单次最多导入5000行', 400, ctx.requestId) + } + const result = await storeAccountManagementService.importStoreProfiles({ + platform: platform as StoreAccountProfilePlatform, + rows, + user: ctx.user, + }) + return jsonSuccess(result, 200, ctx.requestId) }, { requireAuth: true }) } diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/route.ts index f222a1d0..bb86a40e 100644 --- a/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/route.ts @@ -4,10 +4,15 @@ import { ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { storeAccountManagementService } from '@/server/services/store-account-management.service' -const schema = z.object({ +const profileSchema = z.object({ storeName: z.string().trim().min(1).max(240), - platform: z.string().trim().min(1).max(10), - values: z.record(z.string(), z.unknown()).default({}), + platform: z.enum(['JD', 'JX']), + values: z.object({ + merchantNumber: z.string().trim().max(500).optional(), + secondaryMerchantNumber: z.string().trim().max(500).optional(), + merchantStoreId: z.string().trim().max(500).optional(), + companyId: z.string().trim().max(500).optional(), + }).strict(), }).strict() export async function GET(request: NextRequest) { @@ -19,8 +24,14 @@ export async function GET(request: NextRequest) { export async function PATCH(request: NextRequest) { return handleRequest(request, async (ctx) => { - const parsed = schema.safeParse(await request.json().catch(() => null)) - if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '店铺资料内容不正确', 400, ctx.requestId) - return jsonSuccess(await storeAccountManagementService.saveStoreProfile({ ...parsed.data, user: ctx.user }), 200, ctx.requestId) + const body = await request.json().catch(() => null) + const parsed = profileSchema.safeParse(body) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '店铺资料内容不正确', 400, ctx.requestId) + } + return jsonSuccess(await storeAccountManagementService.saveStoreProfile({ + ...parsed.data, + user: ctx.user, + }), 200, ctx.requestId) }, { requireAuth: true }) } diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/template/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/template/route.ts index e71c0b3c..847bcf77 100644 --- a/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/template/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/template/route.ts @@ -1,30 +1,39 @@ import { NextRequest, NextResponse } from 'next/server' -import ExcelJS from 'exceljs' -import { STORE_ACCOUNT_PROFILE_FIELDS, STORE_ACCOUNT_PROFILE_PLATFORMS, type StoreAccountProfilePlatform } from '@/lib/store-account-management' -import { ErrorCode } from '@/server/core/errors' -import { handleRequest, jsonError } from '@/server/core/response' -import { storeAccountManagementAccessService } from '@/server/services/store-account-management-access.service' +import * as XLSX from 'xlsx' +import { + STORE_ACCOUNT_PROFILE_FIELDS, + STORE_ACCOUNT_PROFILE_PLATFORMS, + type StoreAccountProfilePlatform, +} from '@/lib/store-account-management' +import { AppError, ErrorCode } from '@/server/core/errors' +import { handleRequest } from '@/server/core/response' +import { storeAccountManagementService } from '@/server/services/store-account-management.service' export async function GET(request: NextRequest) { return handleRequest(request, async (ctx) => { - const capabilities = await storeAccountManagementAccessService.getCapabilities(ctx.user) - if (!capabilities.pageAccess.edit) return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有编辑店铺资料的权限', 403, ctx.requestId) - const platform = request.nextUrl.searchParams.get('platform')?.toUpperCase() as StoreAccountProfilePlatform - const platformLabel = STORE_ACCOUNT_PROFILE_PLATFORMS.find((item) => item.key === platform)?.label - if (!platformLabel) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '平台只能是京东或京喜', 400, ctx.requestId) - const workbook = new ExcelJS.Workbook() - const sheet = workbook.addWorksheet(`${platformLabel}店铺资料`) - sheet.columns = [{ header: '店铺名称', key: 'storeName', width: 28 }, ...STORE_ACCOUNT_PROFILE_FIELDS.map((field) => ({ header: field.label, key: field.key, width: 28 }))] - sheet.views = [{ state: 'frozen', ySplit: 1 }] - sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } } - sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF27272A' } } - const content = Buffer.from(await workbook.xlsx.writeBuffer()) + const platform = request.nextUrl.searchParams.get('platform')?.trim().toUpperCase() + if (!STORE_ACCOUNT_PROFILE_PLATFORMS.some((item) => item.key === platform)) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '平台只能是京东或京喜') + } + const { target } = await storeAccountManagementService.getFieldAccess(ctx.user) + if (!target.view) { + throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有店铺账号管理权限') + } + const normalizedPlatform = platform as StoreAccountProfilePlatform + const label = normalizedPlatform === 'JD' ? '京东' : '京喜' + const headers = ['店铺名称', ...STORE_ACCOUNT_PROFILE_FIELDS.map((field) => field.label)] + const sheet = XLSX.utils.aoa_to_sheet([headers]) + sheet['!cols'] = headers.map((header) => ({ wch: Math.max(18, header.length * 2 + 4) })) + const workbook = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(workbook, sheet, `${label}店铺资料`) + const content = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer return new NextResponse(new Uint8Array(content), { status: 200, headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(`${platformLabel}店铺资料导入模板.xlsx`)}`, + 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(`${label}店铺资料导入模板.xlsx`)}`, 'Cache-Control': 'private, no-store, max-age=0', + 'X-Content-Type-Options': 'nosniff', }, }) }, { requireAuth: true }) diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/route.ts index 4a3e2854..6411c2a4 100644 --- a/furniture-3d-system/src/app/api/supply-chain/store-account-management/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/route.ts @@ -11,9 +11,11 @@ const createSchema = z.object({ operationDepartmentHead: z.string().trim().max(240).optional(), operationDirector: z.string().trim().max(240).optional(), profitCenter: z.string().trim().max(240).optional(), + subject: z.string().trim().max(240).optional(), accountType: z.string().trim().min(1).max(20), account: z.string().trim().min(1).max(240), password: z.string().max(500).optional(), + boundBankAccountNumber: z.string().trim().max(500).optional(), boundPhone: z.string().trim().max(80).optional(), verificationContact: z.string().trim().max(240).optional(), jstOrderService: z.string().trim().max(240).optional(), diff --git a/furniture-3d-system/src/app/supply-chain/script-monitor/ScriptMonitorView.test.tsx b/furniture-3d-system/src/app/supply-chain/script-monitor/ScriptMonitorView.test.tsx index 43d88f81..045062df 100644 --- a/furniture-3d-system/src/app/supply-chain/script-monitor/ScriptMonitorView.test.tsx +++ b/furniture-3d-system/src/app/supply-chain/script-monitor/ScriptMonitorView.test.tsx @@ -10,6 +10,7 @@ const overview: ScriptMonitorOverview = { hostname: 'warehouse-01', networkMode: 'AUTO', connectionKind: 'INTRANET', + connectionState: 'ONLINE', online: true, isActive: true, os: 'linux 6.8', @@ -30,6 +31,7 @@ const overview: ScriptMonitorOverview = { expectedIntervalSec: 900, status: 'SUCCESS', isEnabled: true, + canManualRun: true, lastStartedAt: '2026-08-28T11:45:00.000Z', lastFinishedAt: '2026-08-28T11:45:12.000Z', lastDurationMs: 12_000, @@ -44,14 +46,18 @@ const overview: ScriptMonitorOverview = { hostname: 'warehouse-01', networkMode: 'AUTO', connectionKind: 'INTRANET', + connectionState: 'ONLINE', online: true, lastSeenAt: '2026-08-28T11:59:30.000Z', }, latestRun: null, + latestCommand: null, }], metrics: { agentTotal: 1, + agentConnected: 1, agentOnline: 1, + agentPending: 0, scriptTotal: 1, healthy: 1, running: 0, @@ -67,9 +73,11 @@ describe('ScriptMonitorView', () => { expect(screen.getByRole('heading', { name: '脚本监控' })).toBeInTheDocument() expect(screen.getByText('供应链产品档案同步')).toBeInTheDocument() expect(screen.getByText('同步商品、仓库和供应商扩展字段')).toBeInTheDocument() - expect(screen.getAllByText('仓库服务器 01')).toHaveLength(2) + expect(screen.getByText('仓库服务器 01')).toBeInTheDocument() expect(screen.getByText('同步 182 条档案')).toBeInTheDocument() - expect(screen.getByText('内网')).toBeInTheDocument() + expect(screen.getByText('在线 · 内网')).toBeInTheDocument() + expect(screen.getAllByText('最近成功')).toHaveLength(2) + expect(screen.getByRole('button', { name: '立即运行' })).toBeEnabled() }) it('keeps the empty state actionable for editors', () => { @@ -78,7 +86,7 @@ describe('ScriptMonitorView', () => { ...overview, agents: [], scripts: [], - metrics: { agentTotal: 0, agentOnline: 0, scriptTotal: 0, healthy: 0, running: 0, abnormal: 0, unknown: 0 }, + metrics: { agentTotal: 0, agentConnected: 0, agentOnline: 0, agentPending: 0, scriptTotal: 0, healthy: 0, running: 0, abnormal: 0, unknown: 0 }, }} canEdit databaseReady @@ -87,4 +95,41 @@ describe('ScriptMonitorView', () => { expect(screen.getByText('还没有接入机器')).toBeInTheDocument() expect(screen.getByRole('button', { name: /接入第一台机器/ })).toBeInTheDocument() }) + + it('shows a never-connected credential as pending without counting it as connected', () => { + render() + + expect(screen.getAllByText('待接入')).toHaveLength(2) + expect(screen.getByText(/不计入已接入机器/)).toBeInTheDocument() + expect(screen.getByText('共 0 台已接入')).toBeInTheDocument() + }) }) diff --git a/furniture-3d-system/src/app/supply-chain/script-monitor/ScriptMonitorView.tsx b/furniture-3d-system/src/app/supply-chain/script-monitor/ScriptMonitorView.tsx index c79732ac..8023b5f4 100644 --- a/furniture-3d-system/src/app/supply-chain/script-monitor/ScriptMonitorView.tsx +++ b/furniture-3d-system/src/app/supply-chain/script-monitor/ScriptMonitorView.tsx @@ -3,11 +3,12 @@ import { Activity, Check, - CircleCheck, + Clock3, Copy, Globe2, LoaderCircle, Network, + Play, Plus, RefreshCw, Search, @@ -35,12 +36,12 @@ type ApiEnvelope = { } const STATUS_META: Record = { - RUNNING: { label: '运行中', className: 'monitor-status-running' }, - SUCCESS: { label: '正常', className: 'monitor-status-success' }, - FAILED: { label: '失败', className: 'monitor-status-failed' }, + RUNNING: { label: '正在运行', className: 'monitor-status-running' }, + SUCCESS: { label: '最近成功', className: 'monitor-status-success' }, + FAILED: { label: '最近失败', className: 'monitor-status-failed' }, SILENT: { label: '超时未运行', className: 'monitor-status-silent' }, DISABLED: { label: '已停用', className: 'monitor-status-disabled' }, - UNKNOWN: { label: '待确认', className: 'monitor-status-unknown' }, + UNKNOWN: { label: '尚无结果', className: 'monitor-status-unknown' }, } const JOB_TYPE_LABELS = { @@ -103,23 +104,55 @@ function StatusBadge({ status }: { status: ScriptMonitorJobStatus }) { ) } -function ConnectionBadge({ script }: { script: ScriptMonitorScriptRow }) { - if (!script.agent.online) { +function AgentConnectionBadge({ + agent, +}: { + agent: ScriptMonitorOverview['agents'][number] | ScriptMonitorScriptRow['agent'] +}) { + if (agent.connectionState === 'PENDING') { return ( - -