You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

347 lines
9.6 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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<string, unknown>;
}
| {
ok: false;
code: "CONFLICT_CHECK_FAILED";
message: string;
details?: Record<string, unknown>;
};
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<string, unknown>;
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<ConflictCheckResult | null> {
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<ConflictCheckResult> {
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<void> {
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<void> {
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<void> {
if (await isCcMock()) {
mockRegisterContainer(containerNo);
}
}