From d58d483944c5baea4933f76ae52bfde13a380916 Mon Sep 17 00:00:00 2001 From: yml Date: Mon, 25 May 2026 17:44:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=9B=91=E6=8E=A7=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=A4=9A=E8=B4=A6=E5=8F=B7=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/data/scheduled-jobs.json | 16 +- .../platform-config/notification-service.ts | 72 ++- .../notification/domain-notifications.ts | 28 +- .../platforms/cloudtentacles/http-client.ts | 2 + .../scheduler/cloudtentacles-health-job.ts | 294 +++++++++- .../services/scheduler/config-service.test.ts | 94 +++ .../src/services/scheduler/config-service.ts | 59 +- .../services/scheduler/scheduler-service.ts | 12 + apps/backend/src/types/admin-write-inputs.ts | 8 + apps/frontend/src/types/admin/index.ts | 3 + .../src/types/admin/platform-config/index.ts | 3 + .../admin/platform-config/scheduled-jobs.ts | 39 ++ .../platform-shops/AdminPlatformShopsView.vue | 2 + .../AdminNotificationScheduledJobsCard.vue | 534 +++++++++++++----- .../AdminPlatformNotificationSection.vue | 8 +- .../admin/platform-shops/composables/types.ts | 8 + .../useAdminNotificationPlatform.ts | 62 +- 17 files changed, 1062 insertions(+), 182 deletions(-) create mode 100644 apps/backend/src/services/scheduler/config-service.test.ts diff --git a/apps/backend/data/scheduled-jobs.json b/apps/backend/data/scheduled-jobs.json index 24d83ca6..650f4da9 100644 --- a/apps/backend/data/scheduled-jobs.json +++ b/apps/backend/data/scheduled-jobs.json @@ -8,7 +8,21 @@ "intervalSeconds": 18000, "cooldownSeconds": 1800, "config": { - "assetThreshold": 500 + "assetThreshold": 500, + "accounts": [ + { + "sourceKey": "default", + "label": "默认账号", + "enabled": true, + "assetThreshold": 100 + }, + { + "sourceKey": "account2", + "label": "备用账号 1", + "enabled": true, + "assetThreshold": 500 + } + ] } } ] diff --git a/apps/backend/src/services/admin/platform-config/notification-service.ts b/apps/backend/src/services/admin/platform-config/notification-service.ts index e83cfea8..b90e51e8 100644 --- a/apps/backend/src/services/admin/platform-config/notification-service.ts +++ b/apps/backend/src/services/admin/platform-config/notification-service.ts @@ -4,6 +4,8 @@ import { saveNotificationConfig, } from '../../notification/config-service.js' import { sendInternalNotification } from '../../notification/notification-service.js' +import { listCloudtentaclesSources } from '../../platforms/cloudtentacles/source-config-service.js' +import { getAllCloudtentaclesSessionStates } from '../../platforms/cloudtentacles/session-state-service.js' import { getScheduledJobsConfig, getScheduledJobsFilePath, @@ -14,7 +16,7 @@ import { reloadScheduledJobs, runScheduledJobNow, } from '../../scheduler/scheduler-service.js' -import { maskSecret } from './mappers.js' +import { maskPhone, maskSecret } from './mappers.js' import type { AdminNotificationConfigInput, @@ -58,6 +60,7 @@ export function getAdminScheduledJobsConfig() { filePath: getScheduledJobsFilePath(), source: mapAdminScheduledJobsConfig(config), runtime: getScheduledJobRuntimeStates(), + cloudtentaclesAccounts: listAdminCloudtentaclesMonitorAccounts(), } } @@ -69,6 +72,7 @@ export function updateAdminScheduledJobsConfig(payload: AdminScheduledJobsConfig filePath: getScheduledJobsFilePath(), source: mapAdminScheduledJobsConfig(saved), runtime: getScheduledJobRuntimeStates(), + cloudtentaclesAccounts: listAdminCloudtentaclesMonitorAccounts(), } } @@ -114,6 +118,11 @@ function mapAdminNotificationConfig(config: JsonObject = {}) { } function mapAdminScheduledJobsConfig(config: JsonObject = {}) { + const cloudtentaclesAccountMap = new Map( + listAdminCloudtentaclesMonitorAccounts() + .map((item) => [item.sourceKey, item]), + ) + return { enabled: config.enabled !== false, jobs: (Array.isArray(config.jobs) ? config.jobs : []).map((item) => ({ @@ -123,8 +132,67 @@ function mapAdminScheduledJobsConfig(config: JsonObject = {}) { intervalSeconds: Number(item.intervalSeconds || 300), cooldownSeconds: Number(item.cooldownSeconds || 1800), config: { - assetThreshold: Number(item.config?.assetThreshold || 500), + assetThreshold: normalizeNonNegativeNumber(item.config?.assetThreshold, 500), + accounts: mapScheduledJobCloudtentaclesAccounts( + item.config?.accounts, + normalizeNonNegativeNumber(item.config?.assetThreshold, 500), + cloudtentaclesAccountMap, + ), }, })), } } + +function listAdminCloudtentaclesMonitorAccounts() { + const sourcesConfig = listCloudtentaclesSources() + const sessionsConfig = getAllCloudtentaclesSessionStates() + const sessions = sessionsConfig.sessions || {} + + return (Array.isArray(sourcesConfig.sources) ? sourcesConfig.sources : []).map((source) => { + const sourceKey = String(source.key || '').trim() + const session = sessions[sourceKey] || {} + const label = String(source.label || source.username || sourceKey).trim() || sourceKey + + return { + sourceKey, + label, + enabled: source.enabled !== false, + username: String(source.username || '').trim(), + phoneMasked: maskPhone(source.phone || session.phone), + hasToken: Boolean(String(session.token || '').trim()), + loggedInAt: String(session.loggedInAt || '').trim(), + } + }).filter((item) => item.sourceKey) +} + +function mapScheduledJobCloudtentaclesAccounts( + rawAccounts: unknown, + defaultAssetThreshold: number, + cloudtentaclesAccountMap: Map, +) { + const accounts = Array.isArray(rawAccounts) ? rawAccounts : [] + return accounts.map((item) => { + const source = isPlainObject(item) ? item : {} + const sourceKey = String(source.sourceKey || source.key || '').trim() + const option = cloudtentaclesAccountMap.get(sourceKey) || {} + + return { + sourceKey, + label: String(source.label || option.label || sourceKey).trim(), + enabled: source.enabled !== false, + assetThreshold: normalizeNonNegativeNumber( + source.assetThreshold ?? source.threshold, + defaultAssetThreshold, + ), + } + }).filter((item) => item.sourceKey) +} + +function normalizeNonNegativeNumber(value: unknown, fallback: number) { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback +} + +function isPlainObject(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} diff --git a/apps/backend/src/services/notification/domain-notifications.ts b/apps/backend/src/services/notification/domain-notifications.ts index ed8d0041..86e531c9 100644 --- a/apps/backend/src/services/notification/domain-notifications.ts +++ b/apps/backend/src/services/notification/domain-notifications.ts @@ -79,16 +79,24 @@ export function notifyCloudtentaclesAuthExpired({ errorCode = '', message = '', cooldownSeconds = 600, + sourceKey = '', + accountLabel = '', }: JsonObject = {}) { + const normalizedSourceKey = String(sourceKey || '').trim() + const normalizedAccountLabel = String(accountLabel || '').trim() + return notifyInternalSafely({ - title: 'cloudtentacles 登录已过期', + title: normalizedAccountLabel + ? `cloudtentacles 登录已过期:${normalizedAccountLabel}` + : 'cloudtentacles 登录已过期', body: [ + normalizedSourceKey ? `账号:${normalizedAccountLabel || normalizedSourceKey}` : '', `接口:${String(pathname || '').trim() || '-'}`, `错误码:${String(errorCode || '').trim() || '-'}`, `原因:${String(message || '').trim() || '登录态已失效,请到后台重新登录'}`, - ].join('\n'), + ].filter(Boolean).join('\n'), category: 'cloudtentacles_auth_expired', - cooldownKey: ['cloudtentacles_auth_expired', pathname, errorCode].join(':'), + cooldownKey: ['cloudtentacles_auth_expired', normalizedSourceKey || 'default', pathname, errorCode].join(':'), cooldownMs: Number(cooldownSeconds || 600) * 1000, }) } @@ -97,16 +105,24 @@ export function notifyCloudtentaclesAssetLow({ asset = 0, threshold = 500, cooldownSeconds = 1800, + sourceKey = '', + accountLabel = '', }: JsonObject = {}) { + const normalizedSourceKey = String(sourceKey || '').trim() + const normalizedAccountLabel = String(accountLabel || '').trim() + return notifyInternalSafely({ - title: '快手 Cloud 余额低于阈值', + title: normalizedAccountLabel + ? `快手 Cloud 余额低于阈值:${normalizedAccountLabel}` + : '快手 Cloud 余额低于阈值', body: [ + normalizedSourceKey ? `账号:${normalizedAccountLabel || normalizedSourceKey}` : '', `当前余额:${Number(asset || 0)}`, `提醒阈值:${Number(threshold || 0)}`, '请及时补充 cloudtentacles 余额,避免自动履约失败。', - ].join('\n'), + ].filter(Boolean).join('\n'), category: 'cloudtentacles_asset_low', - cooldownKey: ['cloudtentacles_asset_low', threshold].join(':'), + cooldownKey: ['cloudtentacles_asset_low', normalizedSourceKey || 'default', threshold].join(':'), cooldownMs: Number(cooldownSeconds || 1800) * 1000, }) } diff --git a/apps/backend/src/services/platforms/cloudtentacles/http-client.ts b/apps/backend/src/services/platforms/cloudtentacles/http-client.ts index b37debf9..cf0063f9 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/http-client.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/http-client.ts @@ -61,6 +61,8 @@ export async function cloudtentaclesRequest(pathname: unknown, options: JsonObje pathname: normalizedPathname, errorCode, message, + sourceKey: options.sourceKey, + accountLabel: options.accountLabel, }) } diff --git a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts b/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts index 092ef9f4..57083d3e 100644 --- a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts +++ b/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts @@ -1,58 +1,296 @@ import { getCloudtentaclesAsset } from '../platforms/cloudtentacles/catalog-service.js' -import { getCloudtentaclesSourceConfig } from '../platforms/cloudtentacles/source-config-service.js' -import { getCloudtentaclesSessionState } from '../platforms/cloudtentacles/session-state-service.js' +import { + getCloudtentaclesSourceByKey, + listCloudtentaclesSources, +} from '../platforms/cloudtentacles/source-config-service.js' +import { getCloudtentaclesSessionStateByKey } from '../platforms/cloudtentacles/session-state-service.js' import { notifyCloudtentaclesAssetLow, notifyCloudtentaclesAuthExpired, } from '../notification/domain-notifications.js' type JsonObject = Record +type CloudtentaclesHealthAccountResult = { + sourceKey: string + label: string + enabled: boolean + hasToken: boolean + ok: boolean + skipped: boolean + status: string + message: string + asset: number | null + threshold: number + checkedAt: string +} export async function runCloudtentaclesHealthJob(job: JsonObject) { - const source = getCloudtentaclesSourceConfig() - const session = getCloudtentaclesSessionState() - const token = String(session.token || '').trim() - const threshold = Number(job.config?.assetThreshold || 500) + const accounts = resolveCloudtentaclesHealthAccounts(job) const cooldownSeconds = Number(job.cooldownSeconds || 1800) + const results = await Promise.all( + accounts.map((account) => runCloudtentaclesHealthAccount(account, cooldownSeconds)), + ) + const checkedResults = results.filter((item) => !item.skipped) + const failedResults = checkedResults.filter((item) => item.ok === false) + const lowAssetResults = checkedResults.filter((item) => item.status === 'asset_low') + const okResults = checkedResults.filter((item) => item.status === 'ok') + const assetValues = checkedResults + .map((item) => item.asset) + .filter((value): value is number => typeof value === 'number' && Number.isFinite(value)) + const summary = buildCloudtentaclesHealthSummary(results) + const status = resolveCloudtentaclesHealthStatus(results) + + return { + ok: failedResults.length === 0, + status, + message: summary, + accountCount: results.length, + checkedCount: checkedResults.length, + okCount: okResults.length, + lowAssetCount: lowAssetResults.length, + failedCount: failedResults.length, + asset: assetValues.length > 0 + ? Math.min(...assetValues) + : null, + threshold: checkedResults.length > 0 + ? Math.max(...checkedResults.map((item) => Number(item.threshold || 0))) + : normalizeNonNegativeInteger(job.config?.assetThreshold, 500), + accounts: results, + } +} + +async function runCloudtentaclesHealthAccount( + account: JsonObject, + cooldownSeconds: number, +): Promise { + const sourceKey = String(account.sourceKey || '').trim() || 'default' + const source = getCloudtentaclesSourceByKey(sourceKey) + const session = getCloudtentaclesSessionStateByKey(sourceKey) || {} + const label = String(account.label || source?.label || source?.username || sourceKey).trim() || sourceKey + const threshold = normalizeNonNegativeInteger(account.assetThreshold, 500) + const checkedAt = new Date().toISOString() + + if (!source) { + return { + sourceKey, + label, + enabled: account.enabled !== false, + hasToken: false, + ok: false, + skipped: false, + status: 'source_missing', + message: `账号 ${label} 不存在`, + asset: null, + threshold, + checkedAt, + } + } + + if (account.enabled === false || source.enabled === false) { + return { + sourceKey, + label, + enabled: false, + hasToken: Boolean(String(session.token || '').trim()), + ok: true, + skipped: true, + status: 'disabled', + message: `账号 ${label} 已停用`, + asset: null, + threshold, + checkedAt, + } + } + + const token = String(session.token || '').trim() if (!token) { await notifyCloudtentaclesAuthExpired({ pathname: '/user/get_asset', errorCode: 'cloudtentacles_token_missing', - message: '当前 cloudtentacles 没有可用 token,请到后台重新登录', + message: `账号 ${label} 没有可用 token,请到后台重新登录`, cooldownSeconds, + sourceKey, + accountLabel: label, }) - return { ok: false, status: 'auth_missing', - message: 'cloudtentacles token 缺失', + message: `账号 ${label} token 缺失`, asset: null, threshold, + sourceKey, + label, + enabled: true, + hasToken: false, + skipped: false, + checkedAt, } } - const assetResult = await getCloudtentaclesAsset({ - baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176', - token, - deviceId: String(session.deviceId || source.deviceId || '-').trim() || '-', - deviceType: Number(session.deviceType ?? source.deviceType ?? 0), - }) - const asset = Number(assetResult.asset || 0) + try { + const assetResult = await getCloudtentaclesAsset({ + sourceKey, + accountLabel: label, + baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176', + token, + deviceId: String(session.deviceId || source.deviceId || '-').trim() || '-', + deviceType: Number(session.deviceType ?? source.deviceType ?? 0), + }) + const asset = Number(assetResult.asset || 0) - if (asset < threshold) { - await notifyCloudtentaclesAssetLow({ + if (asset < threshold) { + await notifyCloudtentaclesAssetLow({ + asset, + threshold, + cooldownSeconds, + sourceKey, + accountLabel: label, + }) + } + + return { + ok: true, + status: asset < threshold ? 'asset_low' : 'ok', + message: asset < threshold + ? `账号 ${label} 余额 ${asset} 低于阈值 ${threshold}` + : `账号 ${label} 余额 ${asset} 正常`, asset, threshold, - cooldownSeconds, - }) - } - - return { - ok: true, - status: asset < threshold ? 'asset_low' : 'ok', - message: asset < threshold ? `余额 ${asset} 低于阈值 ${threshold}` : `余额 ${asset} 正常`, - asset, - threshold, + sourceKey, + label, + enabled: true, + hasToken: true, + skipped: false, + checkedAt, + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error || '余额检查失败') + return { + ok: false, + status: 'failed', + message: `账号 ${label} 检查失败:${message}`, + asset: null, + threshold, + sourceKey, + label, + enabled: true, + hasToken: true, + skipped: false, + checkedAt, + } } } + +function resolveCloudtentaclesHealthAccounts(job: JsonObject) { + const config = isPlainObject(job.config) ? job.config : {} + const rawAccounts = Array.isArray(config.accounts) ? config.accounts : [] + const defaultThreshold = normalizeNonNegativeInteger(config.assetThreshold, 500) + const sourceConfig = listCloudtentaclesSources() + const sourceMap = new Map( + (Array.isArray(sourceConfig.sources) ? sourceConfig.sources : []) + .map((source) => [String(source.key || '').trim(), source]), + ) + const normalizedAccounts = rawAccounts + .map((item) => normalizeCloudtentaclesHealthAccount(item, defaultThreshold, sourceMap)) + .filter(Boolean) as JsonObject[] + + if (normalizedAccounts.length > 0) { + return dedupeAccounts(normalizedAccounts) + } + + return [{ + sourceKey: 'default', + label: sourceMap.get('default')?.label || '默认账号', + enabled: true, + assetThreshold: defaultThreshold, + }] +} + +function normalizeCloudtentaclesHealthAccount( + rawValue: unknown, + defaultThreshold: number, + sourceMap: Map, +) { + const source = isPlainObject(rawValue) ? rawValue : {} + const sourceKey = String(source.sourceKey || source.key || '').trim() + if (!sourceKey) { + return null + } + + const sourceConfig = sourceMap.get(sourceKey) || {} + + return { + sourceKey, + label: String(source.label || sourceConfig.label || sourceConfig.username || sourceKey).trim(), + enabled: source.enabled !== false, + assetThreshold: normalizeNonNegativeInteger( + source.assetThreshold ?? source.threshold, + defaultThreshold, + ), + } +} + +function dedupeAccounts(accounts: JsonObject[]) { + const seen = new Set() + return accounts.filter((item) => { + const sourceKey = String(item.sourceKey || '').trim() + if (!sourceKey || seen.has(sourceKey)) { + return false + } + seen.add(sourceKey) + return true + }) +} + +function buildCloudtentaclesHealthSummary(results: CloudtentaclesHealthAccountResult[]) { + if (results.length === 0) { + return '未配置 cloudtentacles 监控账号' + } + + const checkedResults = results.filter((item) => !item.skipped) + const lowAssetResults = checkedResults.filter((item) => item.status === 'asset_low') + const failedResults = checkedResults.filter((item) => item.ok === false) + const disabledCount = results.length - checkedResults.length + + if (checkedResults.length === 0) { + return `所有账号均已停用,共 ${disabledCount} 个` + } + + if (failedResults.length > 0 || lowAssetResults.length > 0) { + return [ + `检查 ${checkedResults.length} 个账号`, + lowAssetResults.length > 0 ? `余额预警 ${lowAssetResults.length} 个` : '', + failedResults.length > 0 ? `异常 ${failedResults.length} 个` : '', + disabledCount > 0 ? `停用 ${disabledCount} 个` : '', + ].filter(Boolean).join(',') + } + + return `检查 ${checkedResults.length} 个账号,余额均正常${disabledCount > 0 ? `,停用 ${disabledCount} 个` : ''}` +} + +function resolveCloudtentaclesHealthStatus(results: CloudtentaclesHealthAccountResult[]) { + const checkedResults = results.filter((item) => !item.skipped) + if (checkedResults.length === 0) { + return 'skipped' + } + + if (checkedResults.some((item) => ['auth_missing', 'source_missing', 'failed'].includes(item.status))) { + return 'failed' + } + + if (checkedResults.some((item) => item.status === 'asset_low')) { + return 'asset_low' + } + + return 'ok' +} + +function normalizeNonNegativeInteger(value: unknown, fallback: number) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback +} + +function isPlainObject(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} diff --git a/apps/backend/src/services/scheduler/config-service.test.ts b/apps/backend/src/services/scheduler/config-service.test.ts new file mode 100644 index 00000000..f7949b37 --- /dev/null +++ b/apps/backend/src/services/scheduler/config-service.test.ts @@ -0,0 +1,94 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { normalizeScheduledJobsConfig } from './config-service.js' + +test('normalizeScheduledJobsConfig 将旧版 cloudtentacles 阈值迁移为默认账号配置', () => { + const config = normalizeScheduledJobsConfig({ + enabled: true, + jobs: [ + { + id: 'cloudtentacles-health', + type: 'cloudtentacles_health', + enabled: true, + intervalSeconds: 18000, + cooldownSeconds: 1800, + config: { + assetThreshold: 100, + }, + }, + ], + }) + + assert.deepEqual(config.jobs[0], { + id: 'cloudtentacles-health', + type: 'cloudtentacles_health', + enabled: true, + intervalSeconds: 18000, + cooldownSeconds: 1800, + config: { + assetThreshold: 100, + accounts: [ + { + sourceKey: 'default', + label: '', + enabled: true, + assetThreshold: 100, + }, + ], + }, + }) +}) + +test('normalizeScheduledJobsConfig 支持多账号去重并保留 0 阈值', () => { + const config = normalizeScheduledJobsConfig({ + jobs: [ + { + id: 'cloudtentacles-health', + type: 'cloudtentacles_health', + enabled: true, + intervalSeconds: 30, + cooldownSeconds: 10, + config: { + assetThreshold: 500, + accounts: [ + { + sourceKey: 'default', + label: '默认账号', + enabled: true, + assetThreshold: 0, + }, + { + sourceKey: 'default', + label: '重复账号', + enabled: false, + assetThreshold: 999, + }, + { + sourceKey: 'account2', + enabled: false, + threshold: 200, + }, + ], + }, + }, + ], + }) + + assert.deepEqual(config.jobs[0].config.accounts, [ + { + sourceKey: 'default', + label: '默认账号', + enabled: true, + assetThreshold: 0, + }, + { + sourceKey: 'account2', + label: '', + enabled: false, + assetThreshold: 200, + }, + ]) + assert.equal(config.jobs[0].intervalSeconds, 60) + assert.equal(config.jobs[0].cooldownSeconds, 60) +}) diff --git a/apps/backend/src/services/scheduler/config-service.ts b/apps/backend/src/services/scheduler/config-service.ts index b89fb852..3aa053da 100644 --- a/apps/backend/src/services/scheduler/config-service.ts +++ b/apps/backend/src/services/scheduler/config-service.ts @@ -34,7 +34,7 @@ function loadScheduledJobsConfigFromFile() { ) } -function normalizeScheduledJobsConfig(rawValue: unknown) { +export function normalizeScheduledJobsConfig(rawValue: unknown) { const source = isPlainObject(rawValue) ? rawValue : {} const rawJobs = Array.isArray(source.jobs) ? source.jobs : [] const jobs = rawJobs.map((item) => normalizeScheduledJob(item)).filter(Boolean) @@ -65,6 +65,7 @@ function normalizeScheduledJob(rawValue: unknown) { function normalizeCloudtentaclesHealthJob(rawValue: JsonObject) { const config = isPlainObject(rawValue.config) ? rawValue.config : {} + const defaultAssetThreshold = normalizeRangeInteger(config.assetThreshold, 500, 0, 999999) return { id: CLOUDTENTACLES_HEALTH_JOB_ID, @@ -73,11 +74,57 @@ function normalizeCloudtentaclesHealthJob(rawValue: JsonObject) { intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 86400), cooldownSeconds: normalizeRangeInteger(rawValue.cooldownSeconds, 1800, 60, 86400), config: { - assetThreshold: normalizeRangeInteger(config.assetThreshold, 500, 0, 999999), + assetThreshold: defaultAssetThreshold, + accounts: normalizeCloudtentaclesHealthAccounts(config, defaultAssetThreshold), }, } } +function normalizeCloudtentaclesHealthAccounts(config: JsonObject, defaultAssetThreshold: number) { + const rawAccounts = Array.isArray(config.accounts) + ? config.accounts + : Array.isArray(config.sourceKeys) + ? config.sourceKeys.map((sourceKey) => ({ sourceKey })) + : [] + const accounts = rawAccounts + .map((item) => normalizeCloudtentaclesHealthAccount(item, defaultAssetThreshold)) + .filter(Boolean) as ReturnType[] + + if (accounts.length === 0) { + accounts.push(normalizeCloudtentaclesHealthAccount({ + sourceKey: config.sourceKey || 'default', + assetThreshold: defaultAssetThreshold, + }, defaultAssetThreshold)) + } + + const seen = new Set() + return accounts.filter((item) => { + const sourceKey = String(item?.sourceKey || '').trim() + if (!sourceKey || seen.has(sourceKey)) { + return false + } + seen.add(sourceKey) + return true + }) +} + +function normalizeCloudtentaclesHealthAccount(rawValue: unknown, defaultAssetThreshold: number) { + const source = isPlainObject(rawValue) ? rawValue : {} + const sourceKey = String(source.sourceKey || source.key || '').trim() || 'default' + + return { + sourceKey, + label: String(source.label || '').trim(), + enabled: source.enabled !== false, + assetThreshold: normalizeRangeInteger( + source.assetThreshold ?? source.threshold, + defaultAssetThreshold, + 0, + 999999, + ), + } +} + function createDefaultScheduledJobsConfig() { return { enabled: true, @@ -96,6 +143,14 @@ function createDefaultCloudtentaclesHealthJob() { cooldownSeconds: 1800, config: { assetThreshold: 500, + accounts: [ + { + sourceKey: 'default', + label: '默认账号', + enabled: true, + assetThreshold: 500, + }, + ], }, } } diff --git a/apps/backend/src/services/scheduler/scheduler-service.ts b/apps/backend/src/services/scheduler/scheduler-service.ts index 3922633f..2468a926 100644 --- a/apps/backend/src/services/scheduler/scheduler-service.ts +++ b/apps/backend/src/services/scheduler/scheduler-service.ts @@ -130,6 +130,12 @@ async function runJob(job: JsonObject, { manual = false }: { manual?: boolean } lastMessage: result?.message || '执行完成', lastAsset: typeof result?.asset === 'number' ? result.asset : null, lastThreshold: typeof result?.threshold === 'number' ? result.threshold : null, + lastAccounts: Array.isArray(result?.accounts) ? result.accounts : [], + lastAccountCount: Number(result?.accountCount || 0), + lastCheckedCount: Number(result?.checkedCount || 0), + lastOkCount: Number(result?.okCount || 0), + lastLowAssetCount: Number(result?.lowAssetCount || 0), + lastFailedCount: Number(result?.failedCount || 0), lastManual: manual, }) logInfo('[scheduler]', '定时任务执行完成', { @@ -180,6 +186,12 @@ function updateJobState(jobId: string, patch: JsonObject) { lastMessage: '', lastAsset: null, lastThreshold: null, + lastAccounts: [], + lastAccountCount: 0, + lastCheckedCount: 0, + lastOkCount: 0, + lastLowAssetCount: 0, + lastFailedCount: 0, lastManual: false, } jobStates.set(jobId, { diff --git a/apps/backend/src/types/admin-write-inputs.ts b/apps/backend/src/types/admin-write-inputs.ts index 4476f956..2abaca02 100644 --- a/apps/backend/src/types/admin-write-inputs.ts +++ b/apps/backend/src/types/admin-write-inputs.ts @@ -139,6 +139,14 @@ export type AdminScheduledJobInput = { cooldownSeconds?: number | string config?: { assetThreshold?: number | string + accounts?: Array<{ + sourceKey?: string + key?: string + label?: string + enabled?: boolean + assetThreshold?: number | string + threshold?: number | string + }> } } diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index 872b20ce..5c4c2761 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -64,6 +64,9 @@ export type { AdminNotificationConfig, AdminNotificationSendResult, AdminNotificationTestResult, + AdminCloudtentaclesMonitorAccount, + AdminScheduledJobAccountRuntime, + AdminScheduledJobCloudtentaclesAccount, AdminScheduledJobItem, AdminScheduledJobsConfig, AdminScheduledJobRuntimeState, diff --git a/apps/frontend/src/types/admin/platform-config/index.ts b/apps/frontend/src/types/admin/platform-config/index.ts index 0ab4c2ca..62af5fb4 100644 --- a/apps/frontend/src/types/admin/platform-config/index.ts +++ b/apps/frontend/src/types/admin/platform-config/index.ts @@ -13,6 +13,9 @@ export type { } from './notifications' export type { + AdminCloudtentaclesMonitorAccount, + AdminScheduledJobAccountRuntime, + AdminScheduledJobCloudtentaclesAccount, AdminScheduledJobItem, AdminScheduledJobsConfig, AdminScheduledJobRuntimeState, diff --git a/apps/frontend/src/types/admin/platform-config/scheduled-jobs.ts b/apps/frontend/src/types/admin/platform-config/scheduled-jobs.ts index 8256bf8b..5a0d82b1 100644 --- a/apps/frontend/src/types/admin/platform-config/scheduled-jobs.ts +++ b/apps/frontend/src/types/admin/platform-config/scheduled-jobs.ts @@ -1,3 +1,10 @@ +export interface AdminScheduledJobCloudtentaclesAccount { + sourceKey: string + label: string + enabled: boolean + assetThreshold: number +} + export interface AdminScheduledJobItem { id: string type: string @@ -6,6 +13,7 @@ export interface AdminScheduledJobItem { cooldownSeconds: number config: { assetThreshold: number + accounts: AdminScheduledJobCloudtentaclesAccount[] } } @@ -25,11 +33,42 @@ export interface AdminScheduledJobRuntimeState { lastMessage: string lastAsset: number | null lastThreshold: number | null + lastAccounts: AdminScheduledJobAccountRuntime[] + lastAccountCount: number + lastCheckedCount: number + lastOkCount: number + lastLowAssetCount: number + lastFailedCount: number lastManual: boolean } +export interface AdminScheduledJobAccountRuntime { + sourceKey: string + label: string + enabled: boolean + hasToken: boolean + ok: boolean + skipped: boolean + status: string + message: string + asset: number | null + threshold: number + checkedAt: string +} + +export interface AdminCloudtentaclesMonitorAccount { + sourceKey: string + label: string + enabled: boolean + username: string + phoneMasked: string + hasToken: boolean + loggedInAt: string +} + export interface AdminScheduledJobsResponse { filePath: string source: AdminScheduledJobsConfig runtime: AdminScheduledJobRuntimeState[] + cloudtentaclesAccounts: AdminCloudtentaclesMonitorAccount[] } diff --git a/apps/frontend/src/views/admin/platform-shops/AdminPlatformShopsView.vue b/apps/frontend/src/views/admin/platform-shops/AdminPlatformShopsView.vue index 409f4a19..72251d16 100644 --- a/apps/frontend/src/views/admin/platform-shops/AdminPlatformShopsView.vue +++ b/apps/frontend/src/views/admin/platform-shops/AdminPlatformShopsView.vue @@ -73,6 +73,7 @@ const { scheduledJobsForm, scheduledJobs, scheduledJobRuntime, + cloudtentaclesMonitorAccounts, scheduledJobsSaving, scheduledJobRunningId, notificationStats, @@ -368,6 +369,7 @@ onMounted(loadConfigs) :scheduled-jobs-form="scheduledJobsForm" :scheduled-jobs="scheduledJobs" :scheduled-job-runtime="scheduledJobRuntime" + :cloudtentacles-monitor-accounts="cloudtentaclesMonitorAccounts" :scheduled-jobs-saving="scheduledJobsSaving" :scheduled-job-running-id="scheduledJobRunningId" :notification-stats="notificationStats" diff --git a/apps/frontend/src/views/admin/platform-shops/components/notification/AdminNotificationScheduledJobsCard.vue b/apps/frontend/src/views/admin/platform-shops/components/notification/AdminNotificationScheduledJobsCard.vue index f3aec27b..0d66577e 100644 --- a/apps/frontend/src/views/admin/platform-shops/components/notification/AdminNotificationScheduledJobsCard.vue +++ b/apps/frontend/src/views/admin/platform-shops/components/notification/AdminNotificationScheduledJobsCard.vue @@ -1,7 +1,16 @@ - + - - - - - - - - - - - - - - - +
+ 预警 + {{ getScheduledJobRuntime(job.id)?.lastLowAssetCount ?? 0 }} +
+
+ 异常 + {{ getScheduledJobRuntime(job.id)?.lastFailedCount ?? 0 }} +
+
+ 上次 + {{ + formatAdminDateTime(getScheduledJobRuntime(job.id)?.lastRunAt || '') + }} +
+
+ 下次 + {{ + formatAdminDateTime(getScheduledJobRuntime(job.id)?.nextRunAt || '') + }} +
+ + + + + + + + + + + + + + + + + + + @@ -226,69 +381,168 @@ defineProps() border-radius: var(--radius-lg); } -.card-actions { +.card-actions, +.job-actions { display: flex; gap: var(--space-2); flex-shrink: 0; flex-wrap: wrap; -} - -.job-params { - display: grid; - gap: var(--space-2); -} - -.jobs-table { - margin-top: var(--space-2); -} - -.job-switch { - margin-top: var(--space-1); -} - -.param-row { - display: flex; align-items: center; +} + +.job-list { + display: grid; + gap: var(--space-3); +} + +.job-panel { + display: grid; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--bg-surface); +} + +.job-panel-head { + display: flex; + justify-content: space-between; + gap: var(--space-3); + align-items: flex-start; +} + +.job-title-row { + display: flex; gap: var(--space-2); + align-items: center; flex-wrap: wrap; } -.param-label { - font-size: var(--text-xs); - color: var(--text-secondary); - font-weight: 600; - min-width: 56px; -} - -.mini-input { - width: 100px; - height: 32px; - padding: 0 8px; - border-radius: var(--radius-sm); - border: 1px solid var(--border-default); - background: var(--bg-surface); - font-size: var(--text-sm); - text-align: center; -} - -.mini-select { - width: 80px; - height: 32px; - padding: 0 4px; - border-radius: var(--radius-sm); - border: 1px solid var(--border-default); - background: var(--bg-surface); - font-size: var(--text-sm); -} - -.param-hint { +.job-subtitle, +.param-hint, +.summary-muted, +.account-meta { font-size: var(--text-xs); color: var(--text-muted); } -.runtime-info { +.job-settings { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: var(--space-3); +} + +.duration-field { + display: grid; + grid-template-columns: 72px minmax(230px, 300px) minmax(180px, 1fr); + gap: var(--space-2); + align-items: center; + min-width: 0; + padding: var(--space-2); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: var(--bg-muted); +} + +.param-label, +.summary-label { + font-size: var(--text-xs); + color: var(--text-secondary); + font-weight: 600; +} + +.param-label { + white-space: nowrap; +} + +.duration-controls { + display: grid; + grid-template-columns: 144px 96px; + gap: var(--space-2); + align-items: center; + min-width: 0; +} + +.duration-input { + width: 144px; +} + +.duration-select { + width: 96px; +} + +.runtime-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--space-2); + padding: var(--space-2); + border-radius: var(--radius-sm); + background: var(--bg-muted); +} + +.summary-item { + display: grid; + grid-template-rows: 18px minmax(24px, auto); + gap: 4px; + min-width: 0; + padding: var(--space-2); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} + +.summary-value { + min-width: 0; + overflow-wrap: anywhere; + font-size: var(--text-sm); + color: var(--text-primary); +} + +.accounts-table { + width: 100%; +} + +.account-title { + font-weight: 600; + color: var(--text-primary); +} + +.threshold-input { + width: 144px; +} + +.account-runtime { display: grid; gap: 2px; +} + +.runtime-line { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; font-size: var(--text-xs); } + +@media (max-width: 720px) { + .job-panel-head { + display: grid; + } + + .job-actions { + justify-content: flex-start; + } + + .duration-field { + grid-template-columns: 1fr; + } + + .duration-controls { + grid-template-columns: minmax(0, 1fr) 96px; + } + + .duration-input { + width: 100%; + } +} diff --git a/apps/frontend/src/views/admin/platform-shops/components/notification/AdminPlatformNotificationSection.vue b/apps/frontend/src/views/admin/platform-shops/components/notification/AdminPlatformNotificationSection.vue index 3e031d4d..07b00bcb 100644 --- a/apps/frontend/src/views/admin/platform-shops/components/notification/AdminPlatformNotificationSection.vue +++ b/apps/frontend/src/views/admin/platform-shops/components/notification/AdminPlatformNotificationSection.vue @@ -1,5 +1,9 @@