Files
alibb-server/backend/src/services/ingest.service.ts
T
2026-08-27 16:11:49 +08:00

121 lines
6.4 KiB
TypeScript

import { getPrisma } from '../lib/prisma.js';
import { batchInsertMessages } from '../utils/batchInsert.js';
import { maxSendTime } from '../utils/time.js';
type Payload = {
inquiry: any;
customer: any | null;
messages: { messageId: string; sendTime: string; sender?: string; direction: string; content: string; translation?: string }[];
};
export async function ingest(p: Payload) {
const prisma = getPrisma();
const { inquiry: iq, customer: cu, messages } = p;
const newLast = maxSendTime(messages);
// 按 customerId 去重:同客户只存 1 条代表 inquiry(同客户询盘详情/聊天一致)。
// 后续同客户询盘 → 追加到代表的 allInquiries,消息归代表,不新建 inquiry。
if (cu?.customerId) {
// 代表 = 该客户最早的询盘(createTime 升序第一);同客户后续询盘追加到 allInquiries
const rep = await prisma.inquiry.findFirst({
where: { customerId: cu.customerId },
orderBy: { createTime: 'asc' },
});
if (rep && rep.inquiryId !== iq.inquiryId) {
const allList: any[] = rep.allInquiries
? JSON.parse(rep.allInquiries)
: [{ inquiryId: rep.inquiryId, createTime: rep.createTime.toISOString(), status: rep.status, msgPreview: rep.msgPreview }];
if (!allList.find((x: any) => x.inquiryId === iq.inquiryId)) {
allList.push({ inquiryId: iq.inquiryId, createTime: iq.createTime, status: iq.status, msgPreview: iq.msgPreview });
}
// 代表 createTime = 该客户所有会话(询盘)里最早的创建时间
const allTimes = allList.map((x: any) => new Date(x.createTime).getTime()).filter((t: number) => !isNaN(t));
const earliest = allTimes.length ? new Date(Math.min(...allTimes)) : new Date(iq.createTime);
await prisma.$transaction(async (tx) => {
if (cu) {
const data = mapCustomer(cu);
await tx.customer.upsert({ where: { customerId: cu.customerId }, create: data, update: skipNulls(data) });
}
await tx.inquiry.update({
where: { inquiryId: rep.inquiryId },
data: {
allInquiries: JSON.stringify(allList),
createTime: earliest,
updateTime: new Date(iq.updateTime),
status: iq.status ?? undefined,
msgPreview: iq.msgPreview ?? undefined,
},
});
});
const { failedBatches } = await batchInsertMessages(rep.inquiryId, messages);
const total = await prisma.chatMessage.count({ where: { inquiryId: rep.inquiryId } });
await prisma.inquiry.update({
where: { inquiryId: rep.inquiryId },
data: { messagesCount: total, lastSendTime: newLast, hasFailedBatches: failedBatches > 0 },
});
return { ok: true, inserted: total, failedBatches, earlyReturn: false };
}
}
// 新客户(或同 inquiryId 代表重抓):customer/inquiry 每次 upsert(保证行为/字段最新);
// 消息只在「有变化」时才 batchInsert(增量,skipDuplicates 去重),没变则跳过省一次批量插入。
const existing = await prisma.inquiry.findUnique({ where: { inquiryId: iq.inquiryId } });
const msgsChanged = !existing || existing.hasFailedBatches
|| existing.messagesCount !== messages.length
|| !(newLast && existing.lastSendTime && existing.lastSendTime.getTime() === newLast.getTime());
// 建/更新代表 inquiry + customer(每次,保证最新)
await prisma.$transaction(async (tx) => {
if (cu) {
const data = mapCustomer(cu);
await tx.customer.upsert({ where: { customerId: cu.customerId }, create: data, update: skipNulls(data) });
}
await tx.inquiry.upsert({
where: { inquiryId: iq.inquiryId },
create: {
inquiryId: iq.inquiryId, customerId: cu?.customerId ?? null, manager: iq.manager,
createTime: new Date(iq.createTime), updateTime: new Date(iq.updateTime),
status: iq.status, msgPreview: iq.msgPreview, isFlagged: iq.isFlagged,
},
update: {
customerId: cu?.customerId ?? null, manager: iq.manager,
createTime: new Date(iq.createTime), updateTime: new Date(iq.updateTime),
status: iq.status, msgPreview: iq.msgPreview, isFlagged: iq.isFlagged,
},
});
});
// 消息没变 → 跳过 batchInsert(customer/inquiry 已更新 above);变了才插入 + 更新计数
if (!msgsChanged) {
return { ok: true, inserted: existing!.messagesCount, failedBatches: 0, earlyReturn: true };
}
const { failedBatches } = await batchInsertMessages(iq.inquiryId, messages);
const total = await prisma.chatMessage.count({ where: { inquiryId: iq.inquiryId } });
await prisma.inquiry.update({
where: { inquiryId: iq.inquiryId },
data: { messagesCount: total, lastSendTime: newLast, hasFailedBatches: failedBatches > 0 },
});
return { ok: true, inserted: total, failedBatches, earlyReturn: false };
}
function mapCustomer(c: any) {
// 空串归一化为 null:避免空串占据 NOT NULL 语义、影响 stats 的 `country IS NOT NULL` 过滤等
const s = (v: any) => (v == null || v === '' ? null : v);
return {
customerId: c.customerId, name: s(c.name), country: s(c.country), email: s(c.email), phone: s(c.phone), landline: s(c.landline), businessType: s(c.businessType), salesPlatform: s(c.salesPlatform), website: s(c.website), company: s(c.company),
registerTime: c.registerTime ? new Date(c.registerTime) : null, avatarUrl: s(c.avatarUrl),
buyerTags: s(c.buyerTags), buyerFeatures: s(c.buyerFeatures),
bProductViews: c.behavior?.productViews ?? null, bValidInquiries: c.behavior?.validInquiries ?? null,
bValidRfq: c.behavior?.validRfq ?? null, bLoginDays: c.behavior?.loginDays ?? null,
bSpamInquiries: c.behavior?.spamInquiries ?? null, bBlacklist: c.behavior?.blacklistCount ?? null, bTopIndustry: c.behavior?.topIndustry ?? null, recentProductsImages: Array.isArray(c.recentInquiryProducts) && c.recentInquiryProducts.length ? JSON.stringify(c.recentInquiryProducts) : null, storeInquiryProduct: s(c.storeInquiryProduct), storeInquiryQty: s(c.storeInquiryQty), storeVisitProducts: Array.isArray(c.storeVisitProducts) && c.storeVisitProducts.length ? JSON.stringify(c.storeVisitProducts) : null,
};
}
// update 时跳过 null 字段(避免覆盖已落库的有效值,如上次抓到 behavior 这次没抓到)
function skipNulls(data: Record<string, any>) {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(data)) if (v != null) out[k] = v;
return out;
}