import { getEnv } from "@/lib/env"; import { getCcOwnerUserId, getCcProfileId } from "@/lib/cc-owner"; import { logger } from "@/lib/logger"; import { prisma } from "@/services/db"; import { ccAuth } from "@/services/cc/auth"; import { CcHttpClient, CcHttpError } from "@/services/cc/http"; import { normalizeCcCode } from "@/services/cc/code"; import { mockContainerExists, mockRegisterContainer, } from "@/services/cc/mock-state"; import { isCcMock } from "@/services/cc/settings-config"; import { toShanghaiDateString } from "@/utils/dates"; import { classifyCcFailure, conflictCheckOperatorMessage, formatCcApiFailure, formatCcCause, } from "@/constants/error-copy"; import { runWithListHttpGate } from "@/services/cc/list-http-gate"; const LIST_PATH = "/Container/GetContainerList"; const ccHttp = new CcHttpClient(ccAuth); /** V1.73:GetContainerList 1 分钟 1 次;成功结果缓存,避免转仓闸门把限流当成失败 */ const LIST_CACHE_TTL_MS = 70_000; const listCache = new Map< string, { at: number; result: { ok: true; items: CcContainerRow[] } } >(); const listInflight = new Map< string, Promise<{ ok: true; items: CcContainerRow[] } | { ok: false; error: string }> >(); function listProfileKey(): string { const profileId = getCcProfileId(); if (profileId != null) return `profile:${profileId}`; const ownerUserId = getCcOwnerUserId(); if (ownerUserId != null) return `owner:${ownerUserId}`; return "global"; } function listCacheKey(profileKey: string, containerNo: string): string { return `${profileKey}:${containerNo}`; } export function isCcRateLimitedMessage(msg: string): boolean { return classifyCcFailure(msg) === "rate_limit"; } export type ConflictCheckResult = | { ok: true } | { ok: false; code: "CONFLICT_CONTAINER"; message: string; details?: Record; } | { ok: false; code: "CONFLICT_CHECK_FAILED"; message: string; details?: Record; }; export interface ConflictCheckInput { mailId: bigint; containerNo: string; forceSkipConflict?: boolean; actor?: string; } interface CcContainerRow { F_ContainerNo?: string; F_Id?: string; F_ContainerTaskId?: string; F_CreateDate?: string; /** DO 文件夹 id */ F_DOFile?: string | null; } function listDateRange(): { startTime: string; endTime: string } { const end = new Date(); const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000); return { startTime: toShanghaiDateString(start) || "", endTime: toShanghaiDateString(end) || "", }; } export async function getContainerListFromCc( containerNo: string, ): Promise<{ ok: true; items: CcContainerRow[] } | { ok: false; error: string }> { const normalized = containerNo.toUpperCase(); const profileKey = listProfileKey(); const cacheKey = listCacheKey(profileKey, normalized); if (await isCcMock()) { if (mockContainerExists(normalized)) { return { ok: true, items: [{ F_ContainerNo: normalized, F_Id: "mock-existing-id" }], }; } return { ok: true, items: [] }; } const cached = listCache.get(cacheKey); if (cached && Date.now() - cached.at < LIST_CACHE_TTL_MS) { return cached.result; } const pending = listInflight.get(cacheKey); if (pending) return pending; const { startTime, endTime } = listDateRange(); // V1.72:data 内 queryJson 必须是 JSON **字符串**(非对象);时间在 queryJson 内 const queryPayload = { pagination: { rows: 50, page: 1, sidx: "F_CreateDate", sord: "desc", }, queryJson: JSON.stringify({ F_ContainerNo: normalized, F_CabinetType: "", F_TransMode: "", F_Status: "", F_FBAID: "", F_ShipmentId: "", StartTime: startTime, EndTime: endTime, F_FBACode: "", }), }; const run = async (): Promise< { ok: true; items: CcContainerRow[] } | { ok: false; error: string } > => { try { const env = getEnv(); const response = await runWithListHttpGate(profileKey, () => ccHttp.request(LIST_PATH, queryPayload, { timeoutMs: env.CC_READ_TIMEOUT_MS, }), ); const code = normalizeCcCode(response.code); if (code !== 200) { const error = formatCcApiFailure({ api: "GetContainerList", code, info: response.info, fallback: `GetContainerList failed: ${code}`, }); if (isCcRateLimitedMessage(String(response.info || error)) && cached?.result.ok) { return cached.result; } return { ok: false, error }; } const items = parseContainerListRows(response.data); const okResult = { ok: true as const, items }; listCache.set(cacheKey, { at: Date.now(), result: okResult }); return okResult; } catch (err) { const msg = err instanceof CcHttpError ? err.timeout ? "GetContainerList timeout" : err.message : "GetContainerList failed"; logger.error({ err, containerNo: normalized }, "cc.list_error"); if (isCcRateLimitedMessage(msg) && cached?.result.ok) { return cached.result; } return { ok: false, error: formatCcCause(msg) }; } }; const promise = run().finally(() => { listInflight.delete(cacheKey); }); listInflight.set(cacheKey, promise); return promise; } function parseContainerListRows(data: unknown): CcContainerRow[] { let parsed: unknown = data; if (typeof data === "string") { try { parsed = JSON.parse(data); } catch { return []; } } if (Array.isArray(parsed)) return parsed as CcContainerRow[]; if (typeof parsed === "object" && parsed) { const obj = parsed as Record; if (Array.isArray(obj.rows)) return obj.rows as CcContainerRow[]; if (Array.isArray(obj.list)) return obj.list as CcContainerRow[]; } return []; } async function checkLocalConflict( mailId: bigint, containerNo: string, ): Promise { const normalized = containerNo.toUpperCase(); const activeLock = await prisma.containerActiveLock.findUnique({ where: { containerNo: normalized }, }); if (activeLock && activeLock.mailId !== mailId) { return { ok: false, code: "CONFLICT_CONTAINER", message: "该柜号正在被其他邮件导入", details: { mailId: activeLock.mailId.toString() }, }; } const otherSuccess = await prisma.containerImport.findFirst({ where: { containerNo: normalized, status: "SUCCESS", mailId: { not: mailId }, }, include: { mail: { select: { id: true, subject: true, messageId: true } } }, }); if (otherSuccess) { return { ok: false, code: "CONFLICT_CONTAINER", message: "该柜号已在其他邮件中导入成功", details: { mailId: otherSuccess.mailId.toString(), subject: otherSuccess.mail.subject, messageId: otherSuccess.mail.messageId, }, }; } const sameMailSuccess = await prisma.containerImport.findFirst({ where: { mailId, containerNo: normalized, status: "SUCCESS", }, }); if (sameMailSuccess) { return { ok: false, code: "CONFLICT_CONTAINER", message: "该柜已在本邮件中导入成功(幂等跳过)", details: { importId: sameMailSuccess.id.toString() }, }; } return null; } export async function checkContainerConflict( input: ConflictCheckInput, ): Promise { const env = getEnv(); const normalized = input.containerNo.toUpperCase(); const local = await checkLocalConflict(input.mailId, normalized); if (local) return local; if (input.forceSkipConflict && env.ENABLE_FORCE_IMPORT) { logger.warn( { mailId: input.mailId.toString(), containerNo: normalized, actor: input.actor }, "cc.conflict_force_skip", ); return { ok: true }; } const listResult = await getContainerListFromCc(normalized); if (!listResult.ok) { if (env.ENABLE_FORCE_IMPORT && input.forceSkipConflict) { return { ok: true }; } const kind = classifyCcFailure(listResult.error); return { ok: false, code: "CONFLICT_CHECK_FAILED", message: conflictCheckOperatorMessage(kind, listResult.error), details: { kind, error: listResult.error }, }; } if (listResult.items.length > 0) { return { ok: false, code: "CONFLICT_CONTAINER", message: "该柜已在 CC 中存在,请确认", details: { items: listResult.items.map((i) => ({ F_ContainerNo: i.F_ContainerNo, F_Id: i.F_Id, })), }, }; } return { ok: true }; } export async function acquireContainerActiveLock(input: { containerNo: string; mailId: bigint; importId: bigint; }): Promise { const normalized = input.containerNo.toUpperCase(); await prisma.containerActiveLock.upsert({ where: { containerNo: normalized }, create: { containerNo: normalized, mailId: input.mailId, importId: input.importId, lockedAt: new Date(), }, update: { mailId: input.mailId, importId: input.importId, lockedAt: new Date(), }, }); } export async function releaseContainerActiveLock(containerNo: string): Promise { const normalized = containerNo.toUpperCase(); await prisma.containerActiveLock .delete({ where: { containerNo: normalized } }) .catch(() => undefined); } /** Mock helper: register container after successful save in tests */ export async function mockMarkContainerExisting(containerNo: string): Promise { if (await isCcMock()) { mockRegisterContainer(containerNo); } }