diff --git a/apps/backend/src/db/migrations/001_init.sql b/apps/backend/src/db/migrations/001_init.sql index 7f6bc9f5..c53ee619 100644 --- a/apps/backend/src/db/migrations/001_init.sql +++ b/apps/backend/src/db/migrations/001_init.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS admin_users ( password_hash TEXT NOT NULL, role TEXT NOT NULL, status TEXT NOT NULL, + session_version INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); diff --git a/apps/backend/src/db/migrations/002_admin_session_version.sql b/apps/backend/src/db/migrations/002_admin_session_version.sql new file mode 100644 index 00000000..7b2e0d62 --- /dev/null +++ b/apps/backend/src/db/migrations/002_admin_session_version.sql @@ -0,0 +1,2 @@ +ALTER TABLE admin_users + ADD COLUMN IF NOT EXISTS session_version INTEGER NOT NULL DEFAULT 1; diff --git a/apps/backend/src/repositories/admin-user-repo.ts b/apps/backend/src/repositories/admin-user-repo.ts index 22044d99..b8f0f99d 100644 --- a/apps/backend/src/repositories/admin-user-repo.ts +++ b/apps/backend/src/repositories/admin-user-repo.ts @@ -6,6 +6,7 @@ type AdminUserRow = { password_hash: string role: string status: string + session_version: number created_at: string updated_at: string } @@ -21,7 +22,7 @@ type AdminUserCreateInput = { type AdminUserPatch = Partial> type AdminUserListInput = { @@ -66,9 +67,10 @@ export async function createAdminUser(input: AdminUserCreateInput): Promise 0 ? parsed : 1 +} + +function nextAdminSessionVersion(user: AdminUserRow): number { + return normalizeAdminSessionVersion(user.session_version) + 1 +} + async function getRequiredAdminUser(userId: number | string): Promise { const user = await getAdminUserById(Number(userId)) diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.ts b/apps/backend/src/services/admin/admin-read-shared-helpers.ts index f111760c..f7a5c27b 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.ts +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.ts @@ -86,7 +86,7 @@ export function mapKuaishouCloudFulfillmentContext(value: unknown): JsonRecord | prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending', cloudSourceKey: String(binding.cloudSourceKey || '').trim(), cloudSourceKeyFallbacks: Array.isArray(binding.cloudSourceKeyFallbacks) - ? binding.cloudSourceKeyFallbacks.map((value) => String(value || '').trim()).filter(Boolean) + ? binding.cloudSourceKeyFallbacks.map((value: unknown) => String(value || '').trim()).filter(Boolean) : [], resolvedSourceKey: String(binding.resolvedSourceKey || '').trim(), skuId: Number(binding.skuId || 0) || 0, 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 b90e51e8..1b419136 100644 --- a/apps/backend/src/services/admin/platform-config/notification-service.ts +++ b/apps/backend/src/services/admin/platform-config/notification-service.ts @@ -95,7 +95,7 @@ function mapAdminNotificationConfig(config: JsonObject = {}) { bark: { enabled: bark.enabled !== false, serverUrl: String(bark.serverUrl || 'https://api.day.app').trim(), - recipients: (Array.isArray(bark.recipients) ? bark.recipients : []).map((item) => ({ + recipients: (Array.isArray(bark.recipients) ? bark.recipients : []).map((item: JsonObject) => ({ id: String(item.id || '').trim(), name: String(item.name || '').trim(), deviceKey: String(item.deviceKey || '').trim(), @@ -105,7 +105,7 @@ function mapAdminNotificationConfig(config: JsonObject = {}) { }, wpush: { enabled: wpush.enabled !== false, - recipients: (Array.isArray(wpush.recipients) ? wpush.recipients : []).map((item) => ({ + recipients: (Array.isArray(wpush.recipients) ? wpush.recipients : []).map((item: JsonObject) => ({ id: String(item.id || '').trim(), name: String(item.name || '').trim(), apiKey: String(item.apiKey || item.apikey || '').trim(), @@ -146,7 +146,7 @@ function mapAdminScheduledJobsConfig(config: JsonObject = {}) { function listAdminCloudtentaclesMonitorAccounts() { const sourcesConfig = listCloudtentaclesSources() const sessionsConfig = getAllCloudtentaclesSessionStates() - const sessions = sessionsConfig.sessions || {} + const sessions: Record = sessionsConfig.sessions || {} return (Array.isArray(sourcesConfig.sources) ? sourcesConfig.sources : []).map((source) => { const sourceKey = String(source.key || '').trim() diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts index a3818bbb..cd31ff02 100644 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts +++ b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts @@ -171,7 +171,7 @@ export async function prepareAdminTaskKuaishouCloudFulfillment( bindUrl: preparedBinding.bindUrl, bindPreparedAt: now, bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), - bindProbeAt: null, + bindProbeAt: null as null, bindProbeStatus: 'pending', bindProbeMessage: '', roleName: '', @@ -181,9 +181,9 @@ export async function prepareAdminTaskKuaishouCloudFulfillment( status: 'pending', name: '', rid: '', - refreshedAt: null, + refreshedAt: null as null, errorMessage: '', - rawInfo: null, + rawInfo: null as null, }, purchase: { ...flowWithResolvedBinding.purchase, diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts index 3ea42216..8d5c0328 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts @@ -4,6 +4,7 @@ import { replaceFulfillmentProfileRequirements, upsertFulfillmentProfile, upsertSkuFulfillmentBinding, + type FulfillmentProfileRequirementInput, } from '../../repositories/fulfillment-profile-repo.js' import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js' import { normalizeProductName } from '../order/product-match-service.js' @@ -14,7 +15,18 @@ import { } from '../order/kuaishou-cloud-fulfillment-config-service.js' import { nowIso } from '../../utils/time.js' -const CORE_PROFILES = [ +type JsonObject = Record +type CoreProfile = { + profileKey: string + name: string + executorKey: string + requiresClaim: boolean + autoDispatch: boolean + inventoryStrategy: string + requirements: FulfillmentProfileRequirementInput[] +} + +const CORE_PROFILES: CoreProfile[] = [ { profileKey: 'manual_review', name: '人工发货', @@ -35,8 +47,6 @@ const CORE_PROFILES = [ }, ] -type JsonObject = Record - export async function ensureFulfillmentCatalogBootstrapped() { const profileMap = await ensureCoreProfiles() await syncConfiguredFulfillmentBindings(profileMap) @@ -44,7 +54,7 @@ export async function ensureFulfillmentCatalogBootstrapped() { async function ensureCoreProfiles() { const timestamp = nowIso() - const profileMap = {} + const profileMap: JsonObject = {} for (const profile of CORE_PROFILES) { const saved = await upsertFulfillmentProfile({ @@ -148,6 +158,6 @@ function resolveBindingRuntimeConfig(binding: JsonObject = {}) { return baseConfig } -function isPlainObject(value) { +function isPlainObject(value: unknown): value is JsonObject { return Object.prototype.toString.call(value) === '[object Object]' } diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index 8f40357a..f0126b70 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -7,10 +7,19 @@ import { formatFenToAmount, normalizeFen } from '../../utils/money.js' import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js' import { nowIso } from '../../utils/time.js' import { buildClaimUrl } from './claim-service.js' +import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository-rows.js' export const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed']) export const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide' +type JsonObject = Record +type ClaimContext = { + claimToken: ClaimTokenRow + task: TaskRow + order: OrderRow + orderItem: OrderItemRow +} + export async function getClaimContext(token: unknown) { const normalized = String(token || '').trim() @@ -76,7 +85,7 @@ export async function getClaimContext(token: unknown) { } } -export function buildClaimDetailPayload({ claimToken, task, order, orderItem }) { +export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) { const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order) return { @@ -113,7 +122,7 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }) skuName: orderItem.sku_name, quantity: orderItem.quantity, }, - session: null, + session: null as null, kuaishouCloudFulfillment, result: task.redeemed_at ? { @@ -126,7 +135,7 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }) } } -export function mapClaimKuaishouCloudFulfillment(task, order) { +export function mapClaimKuaishouCloudFulfillment(task: TaskRow, order: OrderRow) { if (String(task?.executor_key || '').trim() !== 'kuaishou_ct_assisted') { return null } @@ -213,11 +222,11 @@ export function mapClaimKuaishouCloudFulfillment(task, order) { } } -function parseTaskContext(task) { +function parseTaskContext(task: TaskRow): JsonObject { return parseTaskContextValue(task) } -async function expireClaimContext(claimToken, task) { +async function expireClaimContext(claimToken: ClaimTokenRow, task: TaskRow) { const now = nowIso() const nextClaimToken = await updateClaimToken(claimToken.id, { status: 'expired', diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index 8209cacc..397e40dc 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -20,6 +20,7 @@ import { } from '../fulfillment/kuaishou-cloud-task-service.js' import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js' import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' +import type { TaskRow } from '../../types/repository-rows.js' const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs') const ALLOWED_GUIDE_FILES = new Set(['1.png', '2.png', '3.png']) @@ -136,7 +137,7 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js return getKuaishouCloudClaimDetail(token) } -export async function getKuaishouCloudClaimGuideAssetPath(filename) { +export async function getKuaishouCloudClaimGuideAssetPath(filename: unknown) { const normalized = String(filename || '').trim() if (!ALLOWED_GUIDE_FILES.has(normalized)) { throw createHttpError('指引图片不存在', { @@ -172,7 +173,7 @@ export async function getKuaishouCloudClaimDetail(token: unknown) { }) } -function parseTaskContext(task) { +function parseTaskContext(task: Partial | null | undefined): JsonObject { const rawValue = task?.context_json if (!rawValue) { return {} @@ -189,7 +190,7 @@ function parseTaskContext(task) { } } -export async function confirmKuaishouCloudClaimRole(token) { +export async function confirmKuaishouCloudClaimRole(token: unknown) { const context = await getClaimContext(token) const now = nowIso() @@ -245,7 +246,7 @@ export async function confirmKuaishouCloudClaimRole(token) { return getKuaishouCloudClaimDetail(token) } -export async function redeemKuaishouCloudClaim(token) { +export async function redeemKuaishouCloudClaim(token: unknown) { const context = await getClaimContext(token) const now = nowIso() diff --git a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts index f9290902..ad8fcce4 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts @@ -4,8 +4,9 @@ import { probeKuaishouCloudTaskBindUrl, refreshKuaishouCloudTaskRoleInfo, } from '../fulfillment/kuaishou-cloud-task-service.js' +import type { TaskRow } from '../../types/repository-rows.js' -export async function syncKuaishouCloudRoleInfo(task) { +export async function syncKuaishouCloudRoleInfo(task: TaskRow) { const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment) if (!flow.binding.vnId || !flow.binding.vnKey || !flow.binding.vnPhone) { @@ -42,7 +43,7 @@ export async function syncKuaishouCloudRoleInfo(task) { } } -function parseTaskContext(task) { +function parseTaskContext(task: Partial | null | undefined): Record { const rawValue = task?.context_json if (!rawValue) { diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts index 51aca310..40e3d8cb 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts @@ -43,6 +43,7 @@ import { normalizeActor, parseTaskContext, } from "./kuaishou-cloud/task-context.js"; +import type { TaskRow } from "../../types/repository-rows.js"; export { isKuaishouCloudBindUrlFresh, @@ -58,7 +59,7 @@ export { resolvePersistedCloudtentaclesContextWithFallback, } from "./kuaishou-cloud/cloudtentacles-context.js"; -export async function ensureTaskClaimLink(task) { +export async function ensureTaskClaimLink(task: TaskRow) { const tokenStatus = String(task?.primary_claim_token_status || "").trim(); const token = String( task?.primary_claim_token || task?.claim_token || "" @@ -82,7 +83,7 @@ export async function ensureTaskClaimLink(task) { } export async function prepareKuaishouCloudFulfillmentTask( - task, + task: TaskRow, options: JsonObject = {} ) { if (!isKuaishouCloudTask(task)) { @@ -246,7 +247,7 @@ export async function prepareKuaishouCloudFulfillmentTask( bindUrl: preparedBinding.bindUrl, bindPreparedAt: now, bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), - bindProbeAt: null, + bindProbeAt: null as null, bindProbeStatus: "pending", bindProbeMessage: "", roleName: "", @@ -256,9 +257,9 @@ export async function prepareKuaishouCloudFulfillmentTask( status: "pending", name: "", rid: "", - refreshedAt: null, + refreshedAt: null as null, errorMessage: "", - rawInfo: null, + rawInfo: null as null, }, purchase: { ...flowWithResolvedBinding.purchase, @@ -312,7 +313,7 @@ export async function prepareKuaishouCloudFulfillmentTask( } export async function refreshKuaishouCloudTaskBindUrl( - task, + task: TaskRow, options: JsonObject = {} ) { if (!isKuaishouCloudTask(task)) { @@ -413,7 +414,7 @@ export async function refreshKuaishouCloudTaskBindUrl( bindUrl: preparedBinding.bindUrl, bindPreparedAt: now, bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), - bindProbeAt: null, + bindProbeAt: null as null, bindProbeStatus: "pending", bindProbeMessage: "", roleName: @@ -485,7 +486,7 @@ export async function refreshKuaishouCloudTaskBindUrl( } export async function probeKuaishouCloudTaskBindUrl( - task, + task: TaskRow, options: JsonObject = {} ) { if (!isKuaishouCloudTask(task)) { @@ -503,7 +504,7 @@ export async function probeKuaishouCloudTaskBindUrl( return { task, flow, - probe: null, + probe: null as null, }; } @@ -518,7 +519,7 @@ export async function probeKuaishouCloudTaskBindUrl( return { task, flow, - probe: null, + probe: null as null, }; } @@ -582,7 +583,7 @@ export async function probeKuaishouCloudTaskBindUrl( * @param {{ taskContext?: any, flow?: any, now?: string, actor?: any, error?: unknown }} [input] */ async function markKuaishouCloudBindUrlRefreshFailed( - task, + task: TaskRow, { taskContext, flow, now, actor, error }: JsonObject = {} ) { const errorMessage = @@ -648,7 +649,7 @@ async function markKuaishouCloudBindUrlRefreshFailed( } export async function refreshKuaishouCloudTaskRoleInfo( - task, + task: TaskRow, options: JsonObject = {} ) { if (!isKuaishouCloudTask(task)) { diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts index 69b22b04..92cb3120 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts @@ -11,8 +11,8 @@ import { import { KUAISHOU_CLOUD_FIXED_VN_KEY, type JsonObject } from "./domain.js"; export function resolveKuaishouCloudBindingResources( - flow, - { skuItems = [], knapsackItems = [] } = {} + flow: JsonObject, + { skuItems = [], knapsackItems = [] }: { skuItems?: unknown[], knapsackItems?: unknown[] } = {} ) { const normalizedSkuItems = Array.isArray(skuItems) ? skuItems.filter(isCloudSkuLikeItem) @@ -70,7 +70,7 @@ export function resolveKuaishouCloudBindingResources( }; } -export function resolveKuaishouCloudVnKeyCandidates(_input = {}) { +export function resolveKuaishouCloudVnKeyCandidates(_input: JsonObject = {}) { return [KUAISHOU_CLOUD_FIXED_VN_KEY]; } @@ -159,7 +159,7 @@ export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonOb ); } -function collectKuaishouCloudNameCandidates(flow) { +function collectKuaishouCloudNameCandidates(flow: JsonObject) { return Array.from( new Set( [ @@ -171,10 +171,10 @@ function collectKuaishouCloudNameCandidates(flow) { ); } -function findCloudItemByNames(items, nameCandidates, preferredId = 0) { +function findCloudItemByNames(items: JsonObject[], nameCandidates: string[], preferredId = 0) { const normalizedItems = Array.isArray(items) ? items : []; const normalizedNames = nameCandidates - .map((item) => ({ + .map((item: string) => ({ raw: String(item || "").trim(), normalized: normalizeProductName(item), })) @@ -182,34 +182,34 @@ function findCloudItemByNames(items, nameCandidates, preferredId = 0) { if (normalizedNames.length === 0 || normalizedItems.length === 0) { return preferredId > 0 - ? normalizedItems.find((item) => Number(item.id || 0) === preferredId) || + ? normalizedItems.find((item: JsonObject) => Number(item.id || 0) === preferredId) || null : null; } if (preferredId > 0) { const preferred = - normalizedItems.find((item) => Number(item.id || 0) === preferredId) || + normalizedItems.find((item: JsonObject) => Number(item.id || 0) === preferredId) || null; if (preferred) { return preferred; } } - const exactMatches = normalizedItems.filter((item) => { + const exactMatches = normalizedItems.filter((item: JsonObject) => { const itemName = normalizeProductName(item.name); return normalizedNames.some( - (candidate) => candidate.normalized === itemName + (candidate: { normalized: string }) => candidate.normalized === itemName ); }); if (exactMatches.length > 0) { return exactMatches[0]; } - const partialMatches = normalizedItems.filter((item) => { + const partialMatches = normalizedItems.filter((item: JsonObject) => { const itemName = normalizeProductName(item.name); return normalizedNames.some( - (candidate) => + (candidate: { normalized: string }) => itemName.includes(candidate.normalized) || candidate.normalized.includes(itemName) ); @@ -224,13 +224,15 @@ function findCloudItemByNames(items, nameCandidates, preferredId = 0) { return null; } -function isCloudSkuLikeItem(item) { - return Boolean(item) && typeof item === "object" && Number(item.id || 0) > 0; +function isCloudSkuLikeItem(item: unknown): item is JsonObject { + const current = item && typeof item === "object" ? item as JsonObject : {}; + return Number(current.id || 0) > 0; } -function isRecoverableKuaishouCloudVnKeyError(error) { - const errorCode = String(error?.errorCode || error?.code || "").trim(); - const errorMessage = String(error?.message || "").trim(); +function isRecoverableKuaishouCloudVnKeyError(error: unknown) { + const current = error && typeof error === "object" ? error as JsonObject : {}; + const errorCode = String(current.errorCode || current.code || "").trim(); + const errorMessage = String(current.message || "").trim(); return ( errorCode === "cloudtentacles_vn_bind_url_failed" && errorMessage.includes("不支持的游戏类型") diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts index d6977a6c..6ed8118e 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts @@ -45,7 +45,7 @@ export function resolvePersistedCloudtentaclesContext(sourceKey = "default") { */ export function resolvePersistedCloudtentaclesContextWithFallback( primarySourceKey = "default", - fallbacks = [] + fallbacks: unknown[] = [] ) { const candidates = [ String(primarySourceKey || "default").trim() || "default", diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts index 57668f5e..676ce7d1 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts @@ -8,12 +8,13 @@ export const KUAISHOU_CLOUD_FIXED_VN_KEY = "1"; export type JsonObject = Record; -export function isKuaishouCloudTask(task) { - return String(task?.executor_key || "").trim() === "kuaishou_ct_assisted"; +export function isKuaishouCloudTask(task: unknown) { + const source = task && typeof task === "object" ? task as JsonObject : {}; + return String(source.executor_key || "").trim() === "kuaishou_ct_assisted"; } -export function normalizeKuaishouCloudFlow(value) { - const source = value && typeof value === "object" ? value : {}; +export function normalizeKuaishouCloudFlow(value: unknown) { + const source: JsonObject = value && typeof value === "object" ? value as JsonObject : {}; const binding = source.binding && typeof source.binding === "object" ? source.binding : {}; const role = @@ -129,8 +130,8 @@ export function normalizeKuaishouCloudFlow(value) { }; } -export function normalizeKuaishouCloudRoleInfo(value) { - const rawInfo = value && typeof value === "object" ? value : null; +export function normalizeKuaishouCloudRoleInfo(value: unknown) { + const rawInfo: JsonObject | null = value && typeof value === "object" ? value as JsonObject : null; const nestedBindInfo = rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === "object" ? rawInfo.sBindInfo @@ -157,15 +158,15 @@ export function normalizeKuaishouCloudRoleInfo(value) { }; } -export function maskPhone(value) { +export function maskPhone(value: unknown) { return maskPhoneValue(value); } -export function maskCode(value) { +export function maskCode(value: unknown) { return maskCodeValue(value); } -export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt) { +export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt: unknown) { const preparedTime = Date.parse(String(preparedAt || "")); if (!Number.isFinite(preparedTime)) { return null; @@ -176,7 +177,7 @@ export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt) { return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString(); } -export function isKuaishouCloudBindUrlFresh(flow, now = new Date()) { +export function isKuaishouCloudBindUrlFresh(flow: unknown, now = new Date()) { const normalizedFlow = normalizeKuaishouCloudFlow(flow); if (!normalizedFlow.binding.bindUrl) { return false; @@ -195,7 +196,7 @@ export function isKuaishouCloudBindUrlFresh(flow, now = new Date()) { return expiresTime > now.getTime(); } -export function normalizeStringArray(value) { +export function normalizeStringArray(value: unknown) { if (Array.isArray(value)) { return value.map((v) => String(v || "").trim()).filter(Boolean); } diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts index dc28bada..6b259ee2 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts @@ -1,14 +1,18 @@ import { parseTaskContext as parseTaskContextValue } from "../../../utils/task-json.js"; +import type { TaskRow } from "../../../types/repository-rows.js"; -export function normalizeActor(actor) { +type JsonObject = Record; + +export function normalizeActor(actor: unknown) { if (!actor || typeof actor !== "object") { return null; } - const source = String(actor.source || "").trim(); - const userId = Number(actor.userId || 0) || 0; - const username = String(actor.username || "").trim(); - const role = String(actor.role || "").trim(); + const current = actor as JsonObject; + const source = String(current.source || "").trim(); + const userId = Number(current.userId || 0) || 0; + const username = String(current.username || "").trim(); + const role = String(current.role || "").trim(); if (!source && !userId && !username && !role) { return null; @@ -22,19 +26,19 @@ export function normalizeActor(actor) { }; } -export function parseTaskContext(task) { +export function parseTaskContext(task: Partial | null | undefined) { return parseTaskContextValue(task); } -export function getTaskClaimExpiresAt(task) { +export function getTaskClaimExpiresAt(task: Partial | null | undefined) { return task?.claim_expires_at || task?.primary_claim_expires_at || null; } -export function isClaimExpired(expiredAt) { +export function isClaimExpired(expiredAt: unknown) { if (!expiredAt) { return false; } - const timestamp = new Date(expiredAt).getTime(); + const timestamp = new Date(expiredAt instanceof Date ? expiredAt : String(expiredAt || "")).getTime(); return Number.isFinite(timestamp) && timestamp <= Date.now(); } diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts index 90414207..d82576f4 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts @@ -20,9 +20,10 @@ import { } from "./domain.js"; import { resolvePersistedCloudtentaclesContextWithFallback } from "./cloudtentacles-context.js"; import { normalizeActor, parseTaskContext } from "./task-context.js"; +import type { TaskRow } from "../../../types/repository-rows.js"; export async function dispatchKuaishouCloudFulfillmentTask( - task, + task: TaskRow, options: JsonObject = {} ) { if (!isKuaishouCloudTask(task)) { @@ -157,7 +158,7 @@ export async function dispatchKuaishouCloudFulfillmentTask( } export async function returnKuaishouCloudFulfillmentTask( - task, + task: TaskRow, options: JsonObject = {} ) { if (!isKuaishouCloudTask(task)) { diff --git a/apps/backend/src/services/notification/config-service.ts b/apps/backend/src/services/notification/config-service.ts index cdd03559..56472ee8 100644 --- a/apps/backend/src/services/notification/config-service.ts +++ b/apps/backend/src/services/notification/config-service.ts @@ -7,6 +7,13 @@ const NOTIFICATION_CONFIG_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'notificat const DEFAULT_BARK_SERVER_URL = 'https://api.day.app' type JsonObject = Record +type NotificationRecipient = { + id: string + name: string + deviceKey?: string + apiKey?: string + enabled: boolean +} export function getNotificationConfigFilePath() { return NOTIFICATION_CONFIG_FILE_PATH @@ -26,7 +33,7 @@ export function listEnabledBarkRecipients(config: JsonObject = getNotificationCo } return (Array.isArray(config.channels?.bark?.recipients) ? config.channels.bark.recipients : []) - .filter((item) => item.enabled !== false && String(item.deviceKey || '').trim()) + .filter((item: JsonObject) => item.enabled !== false && String(item.deviceKey || '').trim()) } export function listEnabledWpushRecipients(config: JsonObject = getNotificationConfig()) { @@ -35,7 +42,7 @@ export function listEnabledWpushRecipients(config: JsonObject = getNotificationC } return (Array.isArray(config.channels?.wpush?.recipients) ? config.channels.wpush.recipients : []) - .filter((item) => item.enabled !== false && String(item.apiKey || item.apikey || '').trim()) + .filter((item: JsonObject) => item.enabled !== false && String(item.apiKey || item.apikey || '').trim()) } function loadNotificationConfigFromFile() { @@ -58,20 +65,20 @@ export function normalizeNotificationConfig(rawValue: unknown) { enabled: typeof bark.enabled === 'boolean' ? bark.enabled : true, serverUrl: normalizeBarkServerUrl(bark.serverUrl), recipients: (Array.isArray(bark.recipients) ? bark.recipients : []) - .map((item) => normalizeBarkRecipient(item)) + .map((item: unknown) => normalizeBarkRecipient(item)) .filter(Boolean), }, wpush: { enabled: typeof wpush.enabled === 'boolean' ? wpush.enabled : true, recipients: (Array.isArray(wpush.recipients) ? wpush.recipients : []) - .map((item) => normalizeWpushRecipient(item)) + .map((item: unknown) => normalizeWpushRecipient(item)) .filter(Boolean), }, }, } } -function normalizeBarkRecipient(rawValue: unknown) { +function normalizeBarkRecipient(rawValue: unknown): NotificationRecipient | null { if (!isPlainObject(rawValue)) { return null } @@ -97,7 +104,7 @@ function normalizeBarkServerUrl(value: unknown) { return normalized.replace(/\/+$/, '') } -function normalizeWpushRecipient(rawValue: unknown) { +function normalizeWpushRecipient(rawValue: unknown): NotificationRecipient | null { if (!isPlainObject(rawValue)) { return null } @@ -125,11 +132,11 @@ export function createDefaultNotificationConfig() { bark: { enabled: true, serverUrl: DEFAULT_BARK_SERVER_URL, - recipients: [], + recipients: [] as NotificationRecipient[], }, wpush: { enabled: true, - recipients: [], + recipients: [] as NotificationRecipient[], }, }, } diff --git a/apps/backend/src/services/notification/notification-service.ts b/apps/backend/src/services/notification/notification-service.ts index 07646fba..090d5421 100644 --- a/apps/backend/src/services/notification/notification-service.ts +++ b/apps/backend/src/services/notification/notification-service.ts @@ -8,6 +8,7 @@ import { sendBarkNotification } from './bark-service.js' import { sendWpushNotification } from './wpush-service.js' type JsonObject = Record +type NotificationResult = ReturnType type NotificationInput = { title?: string body?: string @@ -31,12 +32,12 @@ export async function sendInternalNotification(input: NotificationInput = {}) { successCount: 0, failedCount: 0, skippedCount: barkRecipients.length + wpushRecipients.length, - results: [], + results: [] as NotificationResult[], } } const results = [ - ...(await Promise.all(barkRecipients.map(async (recipient) => { + ...(await Promise.all(barkRecipients.map(async (recipient: JsonObject) => { try { const result = await sendBarkNotification({ serverUrl: bark.serverUrl, @@ -64,7 +65,7 @@ export async function sendInternalNotification(input: NotificationInput = {}) { ) } }))), - ...(await Promise.all(wpushRecipients.map(async (recipient) => { + ...(await Promise.all(wpushRecipients.map(async (recipient: JsonObject) => { try { const result = await sendWpushNotification({ recipient, diff --git a/apps/backend/src/services/open-91/order-query-service.ts b/apps/backend/src/services/open-91/order-query-service.ts index 0b39f832..7a5b3b73 100644 --- a/apps/backend/src/services/open-91/order-query-service.ts +++ b/apps/backend/src/services/open-91/order-query-service.ts @@ -23,8 +23,11 @@ import { OPEN_91_PROVIDER, resolveOpen91QueryState, } from './shared.js' +import type { OrderRow } from '../../types/repository-rows.js' -export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) { +type JsonObject = Record + +export async function queryOpen91Order(payload: JsonObject = {}, { requestId = '' } = {}) { const config = assertOpen91Config() const normalized = normalizeOpen91QueryPayload(payload) @@ -168,12 +171,12 @@ export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) { return buildOpen91SuccessResponse(responseData) } -function resolveOpen91OrderFailReason(order) { +function resolveOpen91OrderFailReason(order: OrderRow) { const payload = parseJsonObject(order?.raw_payload_json) return String(payload.manualFailedReason || '').trim() } -function parseJsonObject(value) { +function parseJsonObject(value: unknown): JsonObject { if (!value) { return {} } diff --git a/apps/backend/src/services/open-91/shared.ts b/apps/backend/src/services/open-91/shared.ts index 096b28ca..41c47303 100644 --- a/apps/backend/src/services/open-91/shared.ts +++ b/apps/backend/src/services/open-91/shared.ts @@ -45,7 +45,7 @@ export function assertOpen91Config() { return config } -export function normalizeOpen91String(value) { +export function normalizeOpen91String(value: unknown) { return String(value || '').trim() } @@ -187,7 +187,7 @@ export function assertOpen91Signature(params: JsonObject = {}, config = assertOp } } -export function encryptOpen91Cards(cards = [], secret = assertOpen91Config().secret) { +export function encryptOpen91Cards(cards: unknown[] = [], secret = assertOpen91Config().secret) { const normalizedSecret = normalizeOpen91String(secret) if (normalizedSecret.length !== 32) { throw createHttpError('91卡券 cards 加密密钥长度必须为 32 个字符', { @@ -227,7 +227,7 @@ export function buildOpen91ErrorResponse(message: unknown, code = 500) { return { code, message: normalizeOpen91String(message) || '系统错误', - data: null, + data: null as null, } } @@ -323,7 +323,7 @@ export function resolveOpen91QueryState( } } -export function stringifyOpen91SignValue(value) { +export function stringifyOpen91SignValue(value: unknown) { if (typeof value === 'number' && Number.isFinite(value)) { return String(value) } @@ -339,12 +339,12 @@ export function stringifyOpen91SignValue(value) { return String(value) } -function normalizeOpen91BuyNum(value) { +function normalizeOpen91BuyNum(value: unknown) { const parsed = Number(value) return Number.isInteger(parsed) ? parsed : 0 } -function normalizeOpen91OptionalAmount(value) { +function normalizeOpen91OptionalAmount(value: unknown) { const normalized = normalizeOpen91String(value) if (!normalized) { return '' @@ -360,7 +360,7 @@ function normalizeOpen91OptionalAmount(value) { return normalized } -function normalizeOpen91Timestamp(value) { +function normalizeOpen91Timestamp(value: unknown) { const normalized = normalizeOpen91String(value) if (!/^\d{10}$/.test(normalized)) { return 0 diff --git a/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.ts b/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.ts index dea4e65d..44f02040 100644 --- a/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.ts +++ b/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.ts @@ -20,7 +20,7 @@ export function getKuaishouCloudFulfillmentConfig() { return loadKuaishouCloudFulfillmentConfigFromFile(); } -export function saveKuaishouCloudFulfillmentConfig(rawValue) { +export function saveKuaishouCloudFulfillmentConfig(rawValue: unknown) { return writeJsonFile( KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH, rawValue, @@ -94,19 +94,19 @@ function loadKuaishouCloudFulfillmentConfigFromFile() { ); } -function normalizeKuaishouCloudFulfillmentConfig(rawValue) { +function normalizeKuaishouCloudFulfillmentConfig(rawValue: unknown) { const source = isPlainObject(rawValue) ? rawValue : {}; return { enabled: source.enabled !== false, items: Array.isArray(source.items) ? source.items - .map((item) => normalizeKuaishouCloudFulfillmentItem(item)) + .map((item: unknown) => normalizeKuaishouCloudFulfillmentItem(item)) .filter(Boolean) : [], }; } -function normalizeKuaishouCloudFulfillmentItem(rawValue) { +function normalizeKuaishouCloudFulfillmentItem(rawValue: unknown) { if (!isPlainObject(rawValue)) { return null; } @@ -178,17 +178,17 @@ function normalizeKuaishouCloudFulfillmentItem(rawValue) { }; } -function normalizePositiveInteger(value) { +function normalizePositiveInteger(value: unknown) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : 0; } -function normalizeNonNegativeInteger(value, fallback) { +function normalizeNonNegativeInteger(value: unknown, fallback: number) { const parsed = Number(value); return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; } -function normalizePriority(value) { +function normalizePriority(value: unknown) { const parsed = Number(value); if (!Number.isFinite(parsed)) { return 100; @@ -196,19 +196,19 @@ function normalizePriority(value) { return Math.max(1, Math.round(parsed)); } -function isPlainObject(value) { +function isPlainObject(value: unknown): value is JsonObject { return Object.prototype.toString.call(value) === "[object Object]"; } -function normalizeStringArray(value) { +function normalizeStringArray(value: unknown) { if (Array.isArray(value)) { - return value.map((v) => String(v || "").trim()).filter(Boolean); + return value.map((v: unknown) => String(v || "").trim()).filter(Boolean); } // 兼容旧格式:字符串用逗号分隔 if (typeof value === "string") { return value .split(",") - .map((v) => v.trim()) + .map((v: string) => v.trim()) .filter(Boolean); } return []; diff --git a/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts b/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts index 0c22d88f..affde4f3 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts @@ -37,7 +37,7 @@ export async function runCloudtentaclesFullDebugFlow(payload: JsonObject = {}) { const beforeAsset = await runFlowStep('查询购买前余额', () => getCloudtentaclesAsset(payload)) await sleep(350) const skuList = await runFlowStep('查询 SKU 列表', () => listCloudtentaclesSku(payload)) - const targetSku = skuList.items.find((item) => Number(item.id || 0) === skuId) || null + const targetSku = skuList.items.find((item: JsonObject) => Number(item.id || 0) === skuId) || null if (!targetSku) { throw createHttpError(`cloudtentacles 未找到 SKU ${skuId}`, { diff --git a/apps/backend/src/services/platforms/cloudtentacles/session-service.ts b/apps/backend/src/services/platforms/cloudtentacles/session-service.ts index dc47a801..9afe2c75 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/session-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/session-service.ts @@ -176,7 +176,7 @@ export async function validateCloudtentaclesSession(payload: JsonObject = {}) { userInfo: isPlainObject(userInfoResult.payload?.data) ? userInfoResult.payload.data : {}, asset: Number(assetResult.payload?.data || 0), permissions: Array.isArray(permissionResult.payload?.data) - ? permissionResult.payload.data.map((item) => String(item || '').trim()).filter(Boolean) + ? permissionResult.payload.data.map((item: unknown) => String(item || '').trim()).filter(Boolean) : [], } } diff --git a/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts b/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts index c55b7df3..412bb06d 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts @@ -6,6 +6,10 @@ import { PROJECT_ROOT } from '../../../config/runtime.js' const CLOUDTENTACLES_SESSION_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'cloudtentacles-session.json') type JsonObject = Record +type CloudtentaclesSessionState = ReturnType +type CloudtentaclesSessionStatesFile = { + sessions: Record +} export function getCloudtentaclesSessionFilePath() { return CLOUDTENTACLES_SESSION_FILE_PATH @@ -127,7 +131,7 @@ export function pruneCloudtentaclesSessionStates(sourceKeys: unknown[] = []) { // Internal helpers // --------------------------------------------------------------------------- -function loadCloudtentaclesSessionStatesFromFile() { +function loadCloudtentaclesSessionStatesFromFile(): CloudtentaclesSessionStatesFile { if (!fs.existsSync(CLOUDTENTACLES_SESSION_FILE_PATH)) { return createDefaultCloudtentaclesSessionStates() } @@ -162,7 +166,7 @@ function normalizeCloudtentaclesSessionState(rawValue: unknown) { * Handles old format (single object without sessions key) by auto-wrapping * into { sessions: { 'default': ... } }. */ -function normalizeSessionStatesFile(rawValue: unknown) { +function normalizeSessionStatesFile(rawValue: unknown): CloudtentaclesSessionStatesFile { // Old format: { token: 'xxx', ... } (single object, no sessions key) if (isPlainObject(rawValue) && !rawValue.sessions) { return { @@ -194,7 +198,7 @@ function createDefaultCloudtentaclesSessionState() { } } -function createDefaultCloudtentaclesSessionStates() { +function createDefaultCloudtentaclesSessionStates(): CloudtentaclesSessionStatesFile { return { sessions: { 'default': createDefaultCloudtentaclesSessionState(), diff --git a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts b/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts index 9d2c3263..ab9380be 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts @@ -200,7 +200,7 @@ export async function probeCloudtentaclesBindUrl(payload: JsonObject = {}) { finalUrl: bindUrl, reason: 'missing_signature_params', roleInfo: normalizeAmsBindRoleInfo(null), - raw: null, + raw: null as null, } } @@ -247,7 +247,7 @@ export async function probeCloudtentaclesBindUrl(payload: JsonObject = {}) { finalUrl: bindUrl, reason: error instanceof Error ? error.message : String(error || 'probe_failed'), roleInfo: normalizeAmsBindRoleInfo(null), - raw: null, + raw: null as null, durationMs: Date.now() - startedAt, } } @@ -269,7 +269,7 @@ export async function getCloudtentaclesBindInfo(payload: JsonObject = {}) { }) const items = Array.isArray(result.payload?.data) ? result.payload.data : [] - const matchedItem = items.find((item) => Number(item?.id || 0) === id) || items[0] || {} + const matchedItem = items.find((item: JsonObject) => Number(item?.id || 0) === id) || items[0] || {} const bindInfo = parseBindInfo(matchedItem?.bind_info) return { diff --git a/apps/backend/src/services/platforms/ninetyone/order-service.ts b/apps/backend/src/services/platforms/ninetyone/order-service.ts index 797fa4d8..6e6f6af5 100644 --- a/apps/backend/src/services/platforms/ninetyone/order-service.ts +++ b/apps/backend/src/services/platforms/ninetyone/order-service.ts @@ -11,6 +11,7 @@ import { } from '../../open-91/shared.js' import { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' +import type { OrderItemRow, OrderRow } from '../../../types/repository-rows.js' export const OPEN_91_PENDING_CONFIG_STATUS = 'pending_config' export const OPEN_91_MANUAL_FAILED_STATUS = 'manual_failed' @@ -68,7 +69,7 @@ export function buildOpen91SourceEvent(payload: JsonObject = {}, config: JsonObj } } -export async function upsertOpen91PendingOrder(payload = {}, config = {}) { +export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config: JsonObject = {}) { const event = buildOpen91SourceEvent(payload, config) const now = nowIso() const existing = await findOrderByPlatformOrderId({ @@ -127,11 +128,11 @@ export async function upsertOpen91PendingOrder(payload = {}, config = {}) { return { order, orderItems, - tasks: [], + tasks: [] as JsonObject[], } } -export async function retryOpen91Order(orderId) { +export async function retryOpen91Order(orderId: string | number) { const order = await getRequiredOpen91Order(orderId) const items = await listOrderItemsByOrderId(order.id) const event = buildOpen91SourceEventFromOrder(order, items) @@ -154,7 +155,7 @@ export async function retryOpen91Order(orderId) { } } -export async function failOpen91Order(orderId, reason = '') { +export async function failOpen91Order(orderId: string | number, reason = '') { const order = await getRequiredOpen91Order(orderId) const now = nowIso() const rawPayload = parseJsonObject(order.raw_payload_json) @@ -245,7 +246,7 @@ export async function listOpen91Orders({ page = 1, pageSize = 20, status = 'pend } } -export async function getRequiredOpen91Order(orderId) { +export async function getRequiredOpen91Order(orderId: string | number) { const order = await getOrderById(Number(orderId)) if (!order || order.provider !== OPEN_91_PROVIDER || order.platform !== OPEN_91_PLATFORM) { @@ -258,11 +259,11 @@ export async function getRequiredOpen91Order(orderId) { return order } -function buildOpen91SourceEventFromOrder(order, items = []) { +function buildOpen91SourceEventFromOrder(order: OrderRow, items: OrderItemRow[] = []) { const rawPayload = parseJsonObject(order.raw_payload_json) const body = parseJsonObject(rawPayload.body) const normalizedItems = Array.isArray(items) ? items : [] - const eventItems = normalizedItems.map((item) => { + const eventItems = normalizedItems.map((item: OrderItemRow) => { const snapshot = parseJsonObject(item.item_snapshot_json) const productNo = String(snapshot.productNo || snapshot.externalSkuCode || item.sku_code || '').trim() @@ -301,7 +302,7 @@ function buildOpen91SourceEventFromOrder(order, items = []) { } } -function mapOpen91AdminOrderRow(row) { +function mapOpen91AdminOrderRow(row: JsonObject) { const rawPayload = parseJsonObject(row.raw_payload_json) const items = Array.isArray(row.items_json) ? row.items_json : [] const firstItem = items[0] || {} @@ -327,7 +328,7 @@ function mapOpen91AdminOrderRow(row) { } } -function parseJsonObject(value) { +function parseJsonObject(value: unknown): JsonObject { if (!value) { return {} } diff --git a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts b/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts index 57083d3e..a759e859 100644 --- a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts +++ b/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts @@ -65,7 +65,7 @@ async function runCloudtentaclesHealthAccount( ): Promise { const sourceKey = String(account.sourceKey || '').trim() || 'default' const source = getCloudtentaclesSourceByKey(sourceKey) - const session = getCloudtentaclesSessionStateByKey(sourceKey) || {} + const session: JsonObject = 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() diff --git a/apps/backend/src/startup/state.ts b/apps/backend/src/startup/state.ts index 2f0de5f9..53f36847 100644 --- a/apps/backend/src/startup/state.ts +++ b/apps/backend/src/startup/state.ts @@ -1,6 +1,26 @@ -export type StartupState = ReturnType; +type StartupProcessError = { + time: string; + message: string; +}; -export function createStartupState() { +export type StartupState = { + phase: string; + startedAt: string; + core: { + ready: boolean; + running: boolean; + attemptCount: number; + readyAt: string; + lastAttemptAt: string; + lastError: string; + }; + process: { + lastUnhandledRejection: StartupProcessError | null; + lastUncaughtException: StartupProcessError | null; + }; +}; + +export function createStartupState(): StartupState { return { phase: "starting", startedAt: new Date().toISOString(), diff --git a/apps/backend/src/utils/http.ts b/apps/backend/src/utils/http.ts index 7a83dcf5..15c9f918 100644 --- a/apps/backend/src/utils/http.ts +++ b/apps/backend/src/utils/http.ts @@ -29,13 +29,16 @@ export function buildSuccessPayload(data: unknown, msg: string = "ok") { export function buildErrorPayload(error: unknown, fallbackMessage: string) { const classification = classifyRouteError(error); + const message = classification.statusCode >= 500 + ? fallbackMessage || "服务内部错误" + : error instanceof Error ? error.message : fallbackMessage; return { code: 1, - msg: error instanceof Error ? error.message : fallbackMessage, + msg: message, errorCode: classification.errorCode, time: Math.floor(Date.now() / 1000), - data: null, + data: null as null, }; } @@ -62,7 +65,7 @@ export function buildNotFoundPayload(req: Request) { code: 1, msg: `未实现接口: ${req.method} ${req.originalUrl}`, time: Math.floor(Date.now() / 1000), - data: null, + data: null as null, }; } diff --git a/apps/backend/src/utils/logger.test.ts b/apps/backend/src/utils/logger.test.ts index b9d53227..dfe5efe2 100644 --- a/apps/backend/src/utils/logger.test.ts +++ b/apps/backend/src/utils/logger.test.ts @@ -102,3 +102,27 @@ test('formatLogEntry prints nested detail blocks without ansi colors in file mod assert.match(text, /未登录或登录已失效/) assert.doesNotMatch(text, /\u001B\[/) }) + +test('formatLogEntry masks sensitive fields in inline and nested details', () => { + const text = formatLogEntry({ + time: '2026-04-14T10:36:24.974Z', + level: 'info', + scope: '[security]', + message: 'masked', + pid: 4671, + detail: { + token: 'abcdef1234567890', + authorization: 'Bearer abcdef1234567890', + nested: { + cookie: 'sessionid=abcdef1234567890', + note: 'password=super-secret-value', + }, + }, + }, { color: false }) + + assert.doesNotMatch(text, /abcdef1234567890/) + assert.doesNotMatch(text, /super-secret-value/) + assert.match(text, /abcdef\*\*\*\*567890/) + assert.match(text, /Bearer\*\*\*\*567890/) + assert.match(text, /password=super-\*\*\*\*-value/) +}) diff --git a/apps/backend/src/utils/logger.ts b/apps/backend/src/utils/logger.ts index 5f3680fe..ee0f9a8a 100644 --- a/apps/backend/src/utils/logger.ts +++ b/apps/backend/src/utils/logger.ts @@ -5,15 +5,29 @@ import util from 'node:util' import { PROJECT_ROOT, runtimeConfig } from '../config/runtime.js' import type { HttpErrorLike } from './http.js' +import { maskSecret } from './masking.js' -const LOG_LEVEL_PRIORITY = { +type LogLevel = 'debug' | 'info' | 'warn' | 'error' +type LogChannel = 'app' | 'integration' +type LogDetail = unknown +type InlineFields = Record +type LogEntry = { + time: string + level: LogLevel + scope: string + message: string + pid: number + detail?: unknown +} + +const LOG_LEVEL_PRIORITY: Record = { debug: 10, info: 20, warn: 30, error: 40, } -const LEVEL_LABELS = { +const LEVEL_LABELS: Record = { debug: 'DEBUG', info: 'INFO ', warn: 'WARN ', @@ -36,35 +50,48 @@ const LOG_DIR = path.join(DATA_ROOT, 'logs') const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level) const LOG_RETENTION_DAYS = 7 const LEGACY_LOG_FILES = new Set(['app.log', 'integration.log']) +const SENSITIVE_KEY_PATTERN = /token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardno|cardpwd|apikey|api_key|devicekey|session/i +const MAX_SANITIZE_DEPTH = 8 let writeQueue = Promise.resolve() let lastCleanupDateKey = '' -export function createRequestId(prefix = 'req') { +export function createRequestId(prefix = 'req'): string { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` } -export function logDebug(scope, message, detail = undefined) { +export function logDebug(scope: unknown, message: unknown, detail: LogDetail = undefined) { return writeLog('debug', scope, message, detail) } -export function logInfo(scope, message, detail = undefined) { +export function logInfo(scope: unknown, message: unknown, detail: LogDetail = undefined) { return writeLog('info', scope, message, detail) } -export function logWarn(scope, message, detail = undefined) { +export function logWarn(scope: unknown, message: unknown, detail: LogDetail = undefined) { return writeLog('warn', scope, message, detail) } -export function logError(scope, message, detail = undefined) { +export function logError(scope: unknown, message: unknown, detail: LogDetail = undefined) { return writeLog('error', scope, message, detail) } -export function logIntegration(scope, message, detail = undefined, { level = 'info' } = {}) { +export function logIntegration( + scope: unknown, + message: unknown, + detail: LogDetail = undefined, + { level = 'info' }: { level?: LogLevel } = {}, +) { return writeLog(level, scope, message, detail, { channel: 'integration' }) } -function writeLog(level, scope, message, detail, { channel = 'app' } = {}) { +function writeLog( + level: LogLevel, + scope: unknown, + message: unknown, + detail: LogDetail, + { channel = 'app' }: { channel?: LogChannel } = {}, +): LogEntry | null { if (!shouldWriteLog(level, ACTIVE_LOG_LEVEL)) { return null } @@ -83,7 +110,7 @@ function writeLog(level, scope, message, detail, { channel = 'app' } = {}) { return entry } -function enqueueFileWrite(entry, filePath) { +function enqueueFileWrite(entry: LogEntry, filePath: string): void { const line = formatLogEntry(entry, { color: false }) const dateKey = extractLogDateKey(entry.time) @@ -99,12 +126,12 @@ function enqueueFileWrite(entry, filePath) { }) } -function writeConsole(entry) { +function writeConsole(entry: LogEntry): void { const logger = resolveConsoleMethod(entry.level) logger(formatLogEntry(entry, { color: supportsAnsiColor() })) } -function resolveConsoleMethod(level) { +function resolveConsoleMethod(level: unknown) { const normalized = normalizeLogLevel(level) if (normalized === 'error') { @@ -118,26 +145,26 @@ function resolveConsoleMethod(level) { return console.log } -export function normalizeLogLevel(level) { +export function normalizeLogLevel(level: unknown): LogLevel { const normalized = String(level || '').trim().toLowerCase() - return LOG_LEVEL_PRIORITY[normalized] != null ? normalized : 'info' + return isLogLevel(normalized) ? normalized : 'info' } -export function shouldWriteLog(level, configuredLevel = ACTIVE_LOG_LEVEL) { +export function shouldWriteLog(level: unknown, configuredLevel: unknown = ACTIVE_LOG_LEVEL): boolean { const normalizedLevel = normalizeLogLevel(level) const normalizedConfigured = normalizeLogLevel(configuredLevel) return LOG_LEVEL_PRIORITY[normalizedLevel] >= LOG_LEVEL_PRIORITY[normalizedConfigured] } -export function formatLogEntry(entry, { color = false } = {}) { +export function formatLogEntry(entry: Partial, { color = false }: { color?: boolean } = {}): string { const normalizedEntry = { time: String(entry?.time || new Date().toISOString()), level: normalizeLogLevel(entry?.level), scope: String(entry?.scope || 'app').trim() || 'app', message: String(entry?.message || '').trim() || '-', pid: Number(entry?.pid || process.pid), - detail: entry?.detail, + detail: sanitizeLogValue(entry?.detail), } const timestamp = colorize(formatLogTimestamp(normalizedEntry.time), ANSI.gray, color) const level = colorize(LEVEL_LABELS[normalizedEntry.level], resolveLevelColor(normalizedEntry.level), color) @@ -166,16 +193,16 @@ export function formatLogEntry(entry, { color = false } = {}) { return `${firstLine}\n${indentBlock(inspected, ' ')}` } -export function resolveLogFilePath(channel = 'app', time = new Date().toISOString()) { +export function resolveLogFilePath(channel = 'app', time = new Date().toISOString()): string { const normalizedChannel = String(channel || '').trim() === 'integration' ? 'integration' : 'app' return path.join(LOG_DIR, `${normalizedChannel}-${extractLogDateKey(time)}.log`) } export function resolveExpiredLogFilenames( - fileNames = [], + fileNames: string[] = [], referenceTime = new Date().toISOString(), retentionDays = LOG_RETENTION_DAYS, -) { +): string[] { const normalizedRetentionDays = Math.max(1, Number(retentionDays) || LOG_RETENTION_DAYS) const cutoffDateKey = toDateKey(offsetDate(referenceTime, -(normalizedRetentionDays - 1))) @@ -183,8 +210,8 @@ export function resolveExpiredLogFilenames( .filter((fileName) => shouldDeleteLogFileByDate(fileName, cutoffDateKey)) } -export function formatLogTimestamp(value) { - const date = new Date(value) +export function formatLogTimestamp(value: unknown): string { + const date = new Date(value instanceof Date ? value : String(value || '')) if (Number.isNaN(date.getTime())) { return String(value || '').trim() || new Date().toISOString() @@ -193,27 +220,28 @@ export function formatLogTimestamp(value) { return `${formatLocalDate(date)} ${formatLocalTime(date)}.${String(date.getMilliseconds()).padStart(3, '0')} ${formatTimezoneOffset(date)}` } -function resolveDataRoot() { +function resolveDataRoot(): string { const configured = String(runtimeConfig.data?.root || '').trim() return configured ? path.resolve(configured) : path.resolve(PROJECT_ROOT, 'data') } -function normalizeLogValue(value) { +function normalizeLogValue(value: unknown): unknown { if (typeof value === 'undefined') { return undefined } try { - return JSON.parse( + return sanitizeLogValue(JSON.parse( JSON.stringify(value, (_key, current) => { if (current instanceof Error) { const currentError = current as Error & HttpErrorLike return { name: current.name, - message: current.message, - stack: current.stack, + message: sanitizeLogString(current.message), + stack: sanitizeLogString(current.stack), statusCode: currentError.statusCode, errorCode: currentError.errorCode, + context: sanitizeLogValue(currentError.context), } } @@ -223,13 +251,71 @@ function normalizeLogValue(value) { return current }), - ) + )) } catch { - return String(value) + return sanitizeLogString(String(value)) } } -function splitDetailPayload(detail) { +function sanitizeLogValue(value: unknown, key = '', depth = 0): unknown { + if (typeof value === 'undefined') { + return undefined + } + + if (value === null) { + return null + } + + if (isSensitiveLogKey(key)) { + return maskSecret(value) + } + + if (typeof value === 'string') { + return sanitizeLogString(value) + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return value + } + + if (typeof value === 'bigint') { + return String(value) + } + + if (depth >= MAX_SANITIZE_DEPTH) { + return '[MaxDepth]' + } + + if (Array.isArray(value)) { + return value.map((item) => sanitizeLogValue(item, key, depth + 1)) + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([currentKey, currentValue]) => [ + currentKey, + sanitizeLogValue(currentValue, currentKey, depth + 1), + ]), + ) + } + + return sanitizeLogString(String(value)) +} + +function sanitizeLogString(value: unknown): string { + return String(value || '') + .replace(/(Bearer\s+)([A-Za-z0-9._~+/=-]+)/gi, (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`) + .replace( + /\b((?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key)=)([^&\s,;]+)/gi, + (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`, + ) +} + +function isSensitiveLogKey(key: unknown): boolean { + return SENSITIVE_KEY_PATTERN.test(String(key || '').trim()) +} + +function splitDetailPayload(detail: unknown): { inline: InlineFields, block: unknown | null } { if (typeof detail === 'undefined') { return { inline: {}, @@ -252,8 +338,8 @@ function splitDetailPayload(detail) { } if (detail && typeof detail === 'object') { - const inline = {} - const block = {} + const inline: InlineFields = {} + const block: Record = {} for (const [key, value] of Object.entries(detail)) { if (isScalarLogValue(value)) { @@ -276,14 +362,14 @@ function splitDetailPayload(detail) { } } -function formatInlineFields(fields) { +function formatInlineFields(fields: InlineFields): string { return Object.entries(fields) .filter(([, value]) => typeof value !== 'undefined' && value !== '') .map(([key, value]) => `${key}=${formatInlineValue(value)}`) .join(' ') } -function formatInlineValue(value) { +function formatInlineValue(value: unknown): string { if (value === null) { return 'null' } @@ -296,22 +382,22 @@ function formatInlineValue(value) { return /^[A-Za-z0-9_./:@-]+$/.test(text) ? text : JSON.stringify(text) } -function isScalarLogValue(value) { +function isScalarLogValue(value: unknown): value is string | number | boolean | null { return value === null || ['string', 'number', 'boolean'].includes(typeof value) } -function indentBlock(text, indent) { +function indentBlock(text: unknown, indent: string): string { return String(text || '') .split('\n') .map((line) => `${indent}${line}`) .join('\n') } -function supportsAnsiColor() { +function supportsAnsiColor(): boolean { return process.env.NO_COLOR !== '1' && Boolean(process.stdout.isTTY || process.stderr.isTTY) } -function resolveLevelColor(level) { +function resolveLevelColor(level: unknown): string { switch (normalizeLogLevel(level)) { case 'debug': return ANSI.blue @@ -324,7 +410,7 @@ function resolveLevelColor(level) { } } -function colorize(text, ansiCode, enabled) { +function colorize(text: string, ansiCode: string, enabled: boolean): string { if (!enabled || !ansiCode) { return text } @@ -332,15 +418,15 @@ function colorize(text, ansiCode, enabled) { return `${ansiCode}${text}${ANSI.reset}` } -function formatLocalDate(date) { +function formatLocalDate(date: Date): string { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` } -function formatLocalTime(date) { +function formatLocalTime(date: Date): string { return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}` } -function formatTimezoneOffset(date) { +function formatTimezoneOffset(date: Date): string { const totalMinutes = -date.getTimezoneOffset() const sign = totalMinutes >= 0 ? '+' : '-' const absoluteMinutes = Math.abs(totalMinutes) @@ -349,7 +435,7 @@ function formatTimezoneOffset(date) { return `${sign}${hours}:${minutes}` } -async function cleanupExpiredLogsIfNeeded(dateKey) { +async function cleanupExpiredLogsIfNeeded(dateKey: string): Promise { if (!dateKey || dateKey === lastCleanupDateKey) { return } @@ -366,13 +452,13 @@ async function cleanupExpiredLogsIfNeeded(dateKey) { } } -function extractLogDateKey(time) { +function extractLogDateKey(time: unknown): string { const normalized = String(time || '').trim() return /^\d{4}-\d{2}-\d{2}/.test(normalized) ? normalized.slice(0, 10) : toDateKey(normalized) } -function toDateKey(value) { - const date = new Date(value) +function toDateKey(value: unknown): string { + const date = new Date(value instanceof Date ? value : String(value || '')) if (Number.isNaN(date.getTime())) { return new Date().toISOString().slice(0, 10) @@ -381,13 +467,13 @@ function toDateKey(value) { return date.toISOString().slice(0, 10) } -function offsetDate(value, offsetDays) { - const date = new Date(value) +function offsetDate(value: unknown, offsetDays: unknown): Date { + const date = new Date(value instanceof Date ? value : String(value || '')) date.setUTCDate(date.getUTCDate() + Number(offsetDays || 0)) return date } -function shouldDeleteLogFileByDate(fileName, cutoffDateKey) { +function shouldDeleteLogFileByDate(fileName: unknown, cutoffDateKey: string): boolean { if (LEGACY_LOG_FILES.has(String(fileName || '').trim())) { return true } @@ -398,5 +484,9 @@ function shouldDeleteLogFileByDate(fileName, cutoffDateKey) { return false } - return matched[2] < cutoffDateKey + return String(matched[2] || '') < cutoffDateKey +} + +function isLogLevel(value: string): value is LogLevel { + return ['debug', 'info', 'warn', 'error'].includes(value) } diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index a0fdbe5b..cb7c5ef6 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -7,6 +7,7 @@ "checkJs": true, "noEmit": true, "strict": false, + "noImplicitAny": true, "skipLibCheck": true, "types": ["node"], "lib": ["ES2022"] diff --git a/apps/frontend/src/lib/http.ts b/apps/frontend/src/lib/http.ts index 13e55903..dfb08252 100644 --- a/apps/frontend/src/lib/http.ts +++ b/apps/frontend/src/lib/http.ts @@ -106,6 +106,7 @@ function shouldClearAdminSession(errorCode: string) { 'admin_auth_invalid', 'admin_auth_expired', 'admin_auth_user_invalid', + 'admin_auth_stale', ].includes(errorCode) }