remove unused WeCom backend integration
This commit is contained in:
@@ -9,7 +9,6 @@ import { createHomeModuleSnapshot, DEFAULT_HOME_MODULES } from "../src/home-modu
|
||||
import { collectImageUrls, materializeImageUrls } from "../src/media-migration.ts";
|
||||
import { mediaStorage } from "../src/media-storage.ts";
|
||||
import { DEFAULT_SEARCH_PAGE_CONFIG, SEARCH_PAGE_CONFIG_ID } from "../src/search-page.ts";
|
||||
import { getWecomContactWayConfigDefaults } from "../src/wecom.ts";
|
||||
|
||||
type SourceProduct = {
|
||||
id: number;
|
||||
@@ -115,7 +114,6 @@ async function main() {
|
||||
const seededSearchPage = await materializeImageUrls(DEFAULT_SEARCH_PAGE_CONFIG);
|
||||
|
||||
await prisma.auditLog.deleteMany();
|
||||
await prisma.wecomExternalContact.deleteMany();
|
||||
await prisma.siteVersion.deleteMany();
|
||||
await prisma.homeModule.deleteMany();
|
||||
await prisma.searchPageConfig.deleteMany();
|
||||
@@ -137,13 +135,6 @@ async function main() {
|
||||
await prisma.destination.deleteMany();
|
||||
await prisma.mediaAsset.deleteMany();
|
||||
|
||||
const wecomDefaults = getWecomContactWayConfigDefaults();
|
||||
await prisma.wecomContactWay.upsert({
|
||||
where: { configId: wecomDefaults.configId },
|
||||
update: {},
|
||||
create: wecomDefaults,
|
||||
});
|
||||
|
||||
await prisma.homeModule.createMany({
|
||||
data: seededHomeModules.map((module) => ({
|
||||
...module,
|
||||
|
||||
@@ -38,19 +38,7 @@ import {
|
||||
productUpdateSchema,
|
||||
searchPageConfigSchema,
|
||||
storedImageUrlSchema,
|
||||
wecomContactLinkSchema,
|
||||
wecomContactQuerySchema,
|
||||
wecomContactWayPatchSchema,
|
||||
} from "../schemas.js";
|
||||
import {
|
||||
getWecomContactWay,
|
||||
getWecomContactWayConfigDefaults,
|
||||
getWecomExternalContact,
|
||||
hasWecomApiCredentials,
|
||||
hasWecomCallbackCredentials,
|
||||
WECOM_CONTACT_PLUGIN_ID,
|
||||
WECOM_CONTACT_PLUGIN_VERSION,
|
||||
} from "../wecom.js";
|
||||
|
||||
type SourceProduct = {
|
||||
id: number;
|
||||
@@ -495,39 +483,6 @@ async function audit(actorId: string | undefined, action: string, entity: string
|
||||
});
|
||||
}
|
||||
|
||||
function serializeWecomContactWay(config: {
|
||||
id?: string | null;
|
||||
configId: string;
|
||||
type: number;
|
||||
scene: number;
|
||||
style: number;
|
||||
remark: string | null;
|
||||
state: string | null;
|
||||
wecomUserId: string | null;
|
||||
enabled: boolean;
|
||||
lastSyncedAt?: Date | null;
|
||||
lastSyncError?: string | null;
|
||||
}) {
|
||||
return {
|
||||
id: config.id ?? null,
|
||||
configId: config.configId,
|
||||
type: config.type,
|
||||
scene: config.scene,
|
||||
style: config.style,
|
||||
remark: config.remark,
|
||||
state: config.state,
|
||||
wecomUserId: config.wecomUserId,
|
||||
enabled: config.enabled,
|
||||
lastSyncedAt: config.lastSyncedAt ?? null,
|
||||
lastSyncError: config.lastSyncError ?? null,
|
||||
apiCredentialsConfigured: hasWecomApiCredentials(),
|
||||
callbackCredentialsConfigured: hasWecomCallbackCredentials(),
|
||||
pluginId: WECOM_CONTACT_PLUGIN_ID,
|
||||
pluginVersion: WECOM_CONTACT_PLUGIN_VERSION,
|
||||
callbackPath: "/api/integrations/wecom/callback",
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerAdminRoutes(app: FastifyInstance) {
|
||||
app.post("/api/admin/auth/login", async (request, reply) => {
|
||||
const body = loginSchema.parse(request.body);
|
||||
@@ -1137,213 +1092,6 @@ export async function registerAdminRoutes(app: FastifyInstance) {
|
||||
return { ...lead, phone: lead.phone };
|
||||
});
|
||||
|
||||
app.get("/api/admin/wecom/contact-way", { preHandler: requireAdmin }, async () => {
|
||||
const defaults = getWecomContactWayConfigDefaults();
|
||||
const stored = await prisma.wecomContactWay.findFirst({ orderBy: { updatedAt: "desc" } });
|
||||
return serializeWecomContactWay(stored ?? defaults);
|
||||
});
|
||||
|
||||
app.patch("/api/admin/wecom/contact-way", { preHandler: requireAdmin }, async (request) => {
|
||||
const body = wecomContactWayPatchSchema.parse(request.body);
|
||||
const defaults = getWecomContactWayConfigDefaults();
|
||||
const before = await prisma.wecomContactWay.findFirst({ orderBy: { updatedAt: "desc" } });
|
||||
const saved = before
|
||||
? await prisma.wecomContactWay.update({
|
||||
where: { id: before.id },
|
||||
data: {
|
||||
configId: body.configId,
|
||||
type: body.type ?? before.type,
|
||||
scene: body.scene ?? before.scene,
|
||||
style: body.style ?? before.style,
|
||||
remark: body.remark === undefined ? before.remark : body.remark,
|
||||
state: body.state === undefined ? before.state : body.state,
|
||||
wecomUserId: body.wecomUserId === undefined ? before.wecomUserId : body.wecomUserId,
|
||||
enabled: body.enabled ?? before.enabled,
|
||||
lastSyncError: null,
|
||||
},
|
||||
})
|
||||
: await prisma.wecomContactWay.create({
|
||||
data: {
|
||||
...defaults,
|
||||
...body,
|
||||
type: body.type ?? defaults.type,
|
||||
scene: body.scene ?? defaults.scene,
|
||||
style: body.style ?? defaults.style,
|
||||
remark: body.remark === undefined ? defaults.remark : body.remark,
|
||||
state: body.state === undefined ? defaults.state : body.state,
|
||||
wecomUserId: body.wecomUserId === undefined ? defaults.wecomUserId : body.wecomUserId,
|
||||
enabled: body.enabled ?? defaults.enabled,
|
||||
},
|
||||
});
|
||||
const response = serializeWecomContactWay(saved);
|
||||
await audit(getActorId(request), "update", "wecom_contact_way", saved.id, response, before);
|
||||
return response;
|
||||
});
|
||||
|
||||
app.post("/api/admin/wecom/contact-way/sync", { preHandler: requireAdmin }, async (request, reply) => {
|
||||
const defaults = getWecomContactWayConfigDefaults();
|
||||
const current = await prisma.wecomContactWay.findFirst({ orderBy: { updatedAt: "desc" } });
|
||||
const local = current ?? await prisma.wecomContactWay.create({ data: defaults });
|
||||
if (!hasWecomApiCredentials()) return reply.status(503).send({ message: "请先配置企微服务端凭证" });
|
||||
try {
|
||||
const remote = await getWecomContactWay(local.configId);
|
||||
const remoteConfig = remote.config ?? {};
|
||||
const saved = await prisma.wecomContactWay.update({
|
||||
where: { id: local.id },
|
||||
data: {
|
||||
configId: remoteConfig.config_id ?? local.configId,
|
||||
type: remoteConfig.type ?? local.type,
|
||||
scene: remoteConfig.scene ?? local.scene,
|
||||
style: remoteConfig.style ?? local.style,
|
||||
remark: remoteConfig.remark ?? local.remark,
|
||||
state: remoteConfig.state ?? local.state,
|
||||
wecomUserId: remoteConfig.user ?? local.wecomUserId,
|
||||
lastSyncedAt: new Date(),
|
||||
lastSyncError: null,
|
||||
},
|
||||
});
|
||||
const response = serializeWecomContactWay(saved);
|
||||
await audit(getActorId(request), "sync", "wecom_contact_way", saved.id, response, local);
|
||||
return response;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "企微配置同步失败";
|
||||
await prisma.wecomContactWay.update({ where: { id: local.id }, data: { lastSyncError: message } });
|
||||
return reply.status(502).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/admin/wecom/contacts", { preHandler: requireAdmin }, async (request) => {
|
||||
const query = wecomContactQuerySchema.parse(request.query);
|
||||
const where = {
|
||||
status: query.status || undefined,
|
||||
OR: query.keyword
|
||||
? [
|
||||
{ name: { contains: query.keyword, mode: "insensitive" as const } },
|
||||
{ externalUserId: { contains: query.keyword } },
|
||||
{ wecomUserId: { contains: query.keyword } },
|
||||
{ remark: { contains: query.keyword, mode: "insensitive" as const } },
|
||||
{ corpName: { contains: query.keyword, mode: "insensitive" as const } },
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
const [contacts, total] = await Promise.all([
|
||||
prisma.wecomExternalContact.findMany({
|
||||
where,
|
||||
include: {
|
||||
_count: { select: { leads: true } },
|
||||
contactWay: { select: { id: true, configId: true, style: true } },
|
||||
miniProgramUser: { select: { id: true, nickname: true, phone: true } },
|
||||
customer: { select: { id: true, name: true, phone: true } },
|
||||
},
|
||||
orderBy: { lastEventAt: "desc" },
|
||||
take: query.take,
|
||||
skip: query.skip,
|
||||
}),
|
||||
prisma.wecomExternalContact.count({ where }),
|
||||
]);
|
||||
return {
|
||||
items: contacts.map((contact) => ({
|
||||
...contact,
|
||||
miniProgramUser: contact.miniProgramUser
|
||||
? { ...contact.miniProgramUser, phoneMasked: maskPhone(contact.miniProgramUser.phone) }
|
||||
: null,
|
||||
customer: contact.customer ? { ...contact.customer, phoneMasked: maskPhone(contact.customer.phone) } : null,
|
||||
leadCount: contact._count.leads,
|
||||
})),
|
||||
total,
|
||||
take: query.take,
|
||||
skip: query.skip,
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/api/admin/wecom/contacts/:id", { preHandler: requireAdmin }, async (request, reply) => {
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
|
||||
const contact = await prisma.wecomExternalContact.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
contactWay: true,
|
||||
miniProgramUser: { select: { id: true, nickname: true, phone: true, customerId: true } },
|
||||
customer: true,
|
||||
leads: { select: { id: true, status: true, destination: true, contactName: true, createdAt: true } },
|
||||
},
|
||||
});
|
||||
if (!contact) return reply.status(404).send({ message: "企微客户不存在" });
|
||||
return {
|
||||
...contact,
|
||||
miniProgramUser: contact.miniProgramUser
|
||||
? { ...contact.miniProgramUser, phoneMasked: maskPhone(contact.miniProgramUser.phone) }
|
||||
: null,
|
||||
customer: contact.customer ? { ...contact.customer, phoneMasked: maskPhone(contact.customer.phone) } : null,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch("/api/admin/wecom/contacts/:id/link", { preHandler: requireAdmin }, async (request, reply) => {
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
|
||||
const body = wecomContactLinkSchema.parse(request.body);
|
||||
const before = await prisma.wecomExternalContact.findUnique({ where: { id } });
|
||||
if (!before) return reply.status(404).send({ message: "企微客户不存在" });
|
||||
|
||||
const saved = await prisma.$transaction(async (transaction) => {
|
||||
let customerId: string | null | undefined;
|
||||
if (body.miniProgramUserId !== undefined) {
|
||||
const user = body.miniProgramUserId
|
||||
? await transaction.miniProgramUser.findUnique({ where: { id: body.miniProgramUserId }, select: { id: true, customerId: true } })
|
||||
: null;
|
||||
if (body.miniProgramUserId && !user) throw new Error("小程序用户不存在");
|
||||
customerId = user?.customerId ?? null;
|
||||
}
|
||||
if (body.leadId !== undefined) {
|
||||
if (body.leadId) {
|
||||
const lead = await transaction.lead.findUnique({ where: { id: body.leadId }, select: { id: true } });
|
||||
if (!lead) throw new Error("需求线索不存在");
|
||||
}
|
||||
await transaction.lead.updateMany({ where: { wecomContactId: id }, data: { wecomContactId: null } });
|
||||
if (body.leadId) await transaction.lead.update({ where: { id: body.leadId }, data: { wecomContactId: id } });
|
||||
}
|
||||
return transaction.wecomExternalContact.update({
|
||||
where: { id },
|
||||
data: {
|
||||
miniProgramUserId: body.miniProgramUserId === undefined ? undefined : body.miniProgramUserId,
|
||||
customerId: body.miniProgramUserId === undefined ? undefined : customerId,
|
||||
},
|
||||
});
|
||||
});
|
||||
await audit(getActorId(request), "link", "wecom_external_contact", saved.id, saved, before);
|
||||
return { id: saved.id, miniProgramUserId: saved.miniProgramUserId, customerId: saved.customerId, leadId: body.leadId ?? undefined };
|
||||
});
|
||||
|
||||
app.post("/api/admin/wecom/contacts/:id/refresh", { preHandler: requireAdmin }, async (request, reply) => {
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
|
||||
const contact = await prisma.wecomExternalContact.findUnique({ where: { id } });
|
||||
if (!contact) return reply.status(404).send({ message: "企微客户不存在" });
|
||||
if (!hasWecomApiCredentials()) return reply.status(503).send({ message: "请先配置企微服务端凭证" });
|
||||
try {
|
||||
const detail = await getWecomExternalContact(contact.externalUserId, contact.wecomUserId);
|
||||
const external = detail.external_contact;
|
||||
const follow = detail.follow_info;
|
||||
const saved = await prisma.wecomExternalContact.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: external?.name ?? contact.name,
|
||||
avatarUrl: external?.avatar ?? contact.avatarUrl,
|
||||
type: external?.type ?? contact.type,
|
||||
gender: external?.gender ?? contact.gender,
|
||||
unionId: external?.unionid ?? contact.unionId,
|
||||
corpName: external?.corp_name ?? contact.corpName,
|
||||
position: external?.position ?? contact.position,
|
||||
remark: follow?.remark ?? contact.remark,
|
||||
description: follow?.description ?? contact.description,
|
||||
profile: external?.external_profile ? (external.external_profile as Prisma.InputJsonValue) : contact.profile ?? undefined,
|
||||
lastEventAt: new Date(),
|
||||
},
|
||||
});
|
||||
await audit(getActorId(request), "refresh", "wecom_external_contact", saved.id, saved, contact);
|
||||
return saved;
|
||||
} catch (error) {
|
||||
return reply.status(502).send({ message: error instanceof Error ? error.message : "企微客户同步失败" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/admin/media-assets", { preHandler: requireAdmin }, async () => {
|
||||
const assets = await prisma.mediaAsset.findMany({ orderBy: { createdAt: "desc" }, take: 200 });
|
||||
return { items: assets };
|
||||
|
||||
@@ -47,20 +47,7 @@ import {
|
||||
productUpdateSchema,
|
||||
searchPageConfigSchema,
|
||||
storedImageUrlSchema,
|
||||
wecomContactQuerySchema,
|
||||
wecomContactWayPatchSchema,
|
||||
} from "../schemas.js";
|
||||
import {
|
||||
decryptWecomCallback,
|
||||
getWecomContactWayConfigDefaults,
|
||||
hasWecomApiCredentials,
|
||||
hasWecomCallbackCredentials,
|
||||
normalizeWecomCallbackEvent,
|
||||
parseWecomXml,
|
||||
verifyWecomCallback,
|
||||
WECOM_CONTACT_PLUGIN_ID,
|
||||
WECOM_CONTACT_PLUGIN_VERSION,
|
||||
} from "../wecom.js";
|
||||
|
||||
type MemoryHomeModule = {
|
||||
id: string;
|
||||
@@ -170,7 +157,6 @@ type MemoryLead = {
|
||||
id: string;
|
||||
requestType: "custom" | "booking";
|
||||
userId?: string | null;
|
||||
wecomContactId?: string | null;
|
||||
destination?: string | null;
|
||||
phone: string;
|
||||
contactName?: string | null;
|
||||
@@ -236,47 +222,6 @@ type MemoryHistory = {
|
||||
lastViewedAt: string;
|
||||
};
|
||||
|
||||
type MemoryWecomContactWay = {
|
||||
id: string;
|
||||
configId: string;
|
||||
type: number;
|
||||
scene: number;
|
||||
style: number;
|
||||
remark: string | null;
|
||||
state: string | null;
|
||||
wecomUserId: string | null;
|
||||
enabled: boolean;
|
||||
lastSyncedAt: string | null;
|
||||
lastSyncError: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type MemoryWecomContact = {
|
||||
id: string;
|
||||
externalUserId: string;
|
||||
wecomUserId: string;
|
||||
contactWayId: string | null;
|
||||
miniProgramUserId: string | null;
|
||||
customerId: string | null;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
type: number | null;
|
||||
gender: number | null;
|
||||
unionId: string | null;
|
||||
corpName: string | null;
|
||||
position: string | null;
|
||||
remark: string | null;
|
||||
description: string | null;
|
||||
profile: Record<string, unknown> | null;
|
||||
status: string;
|
||||
addedAt: string | null;
|
||||
lastEventAt: string;
|
||||
deletedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
leadIds: string[];
|
||||
};
|
||||
|
||||
type SourceProduct = {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -519,14 +464,6 @@ const mediaAssets: Array<{ id: string; url: string; name: string; mimeType: stri
|
||||
const miniProgramUsers: MemoryMiniProgramUser[] = [];
|
||||
const memoryFavorites: MemoryFavorite[] = [];
|
||||
const memoryHistories: MemoryHistory[] = [];
|
||||
const memoryWecomContactWay: MemoryWecomContactWay = {
|
||||
id: "wecom-contact-way-memory",
|
||||
...getWecomContactWayConfigDefaults(),
|
||||
lastSyncedAt: null,
|
||||
lastSyncError: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const memoryWecomContacts: MemoryWecomContact[] = [];
|
||||
|
||||
function memoryMaskPhone(phone?: string | null) {
|
||||
if (!phone) return null;
|
||||
@@ -560,31 +497,6 @@ function serializeMemoryUser(user: MemoryMiniProgramUser) {
|
||||
};
|
||||
}
|
||||
|
||||
function serializeMemoryWecomContactWay() {
|
||||
return {
|
||||
...memoryWecomContactWay,
|
||||
apiCredentialsConfigured: hasWecomApiCredentials(),
|
||||
callbackCredentialsConfigured: hasWecomCallbackCredentials(),
|
||||
pluginId: WECOM_CONTACT_PLUGIN_ID,
|
||||
pluginVersion: WECOM_CONTACT_PLUGIN_VERSION,
|
||||
callbackPath: "/api/integrations/wecom/callback",
|
||||
};
|
||||
}
|
||||
|
||||
function serializeMemoryWecomContact(contact: MemoryWecomContact) {
|
||||
const user = contact.miniProgramUserId ? miniProgramUsers.find((item) => item.id === contact.miniProgramUserId) : undefined;
|
||||
const linkedLeads = leads.filter((lead) => contact.leadIds.includes(lead.id) || lead.wecomContactId === contact.id);
|
||||
return {
|
||||
...contact,
|
||||
miniProgramUser: user
|
||||
? { id: user.id, nickname: user.nickname ?? null, phoneMasked: memoryMaskPhone(user.phone) }
|
||||
: null,
|
||||
customer: contact.customerId ? { id: contact.customerId, name: user?.nickname ?? null, phoneMasked: memoryMaskPhone(user?.phone) } : null,
|
||||
leadCount: linkedLeads.length,
|
||||
leads: linkedLeads.map((lead) => ({ id: lead.id, status: lead.status, destination: lead.destination, contactName: lead.contactName, createdAt: lead.createdAt })),
|
||||
};
|
||||
}
|
||||
|
||||
function findMemoryProduct(id: string) {
|
||||
return products.find((product) => product.id === id || String(product.sourceId) === id);
|
||||
}
|
||||
@@ -1200,83 +1112,6 @@ export async function registerMemoryRoutes(app: FastifyInstance) {
|
||||
return { ...lead, phone: lead.phone };
|
||||
});
|
||||
|
||||
app.get("/api/admin/wecom/contact-way", async () => serializeMemoryWecomContactWay());
|
||||
|
||||
app.patch("/api/admin/wecom/contact-way", async (request) => {
|
||||
const body = wecomContactWayPatchSchema.parse(request.body);
|
||||
memoryWecomContactWay.configId = body.configId;
|
||||
memoryWecomContactWay.type = body.type ?? memoryWecomContactWay.type;
|
||||
memoryWecomContactWay.scene = body.scene ?? memoryWecomContactWay.scene;
|
||||
memoryWecomContactWay.style = body.style ?? memoryWecomContactWay.style;
|
||||
if (body.remark !== undefined) memoryWecomContactWay.remark = body.remark;
|
||||
if (body.state !== undefined) memoryWecomContactWay.state = body.state;
|
||||
if (body.wecomUserId !== undefined) memoryWecomContactWay.wecomUserId = body.wecomUserId;
|
||||
memoryWecomContactWay.enabled = body.enabled ?? memoryWecomContactWay.enabled;
|
||||
memoryWecomContactWay.lastSyncError = null;
|
||||
memoryWecomContactWay.updatedAt = new Date().toISOString();
|
||||
return serializeMemoryWecomContactWay();
|
||||
});
|
||||
|
||||
app.post("/api/admin/wecom/contact-way/sync", async (_request, reply) => {
|
||||
memoryWecomContactWay.lastSyncError = "内存开发库不连接企微远程接口";
|
||||
return reply.status(503).send({ message: memoryWecomContactWay.lastSyncError });
|
||||
});
|
||||
|
||||
app.get("/api/admin/wecom/contacts", async (request) => {
|
||||
const query = wecomContactQuerySchema.parse(request.query);
|
||||
const keyword = query.keyword?.toLowerCase();
|
||||
const matched = memoryWecomContacts.filter((contact) => {
|
||||
if (query.status && contact.status !== query.status) return false;
|
||||
if (!keyword) return true;
|
||||
return [contact.name, contact.externalUserId, contact.wecomUserId, contact.remark, contact.corpName].filter(Boolean).join(" ").toLowerCase().includes(keyword);
|
||||
});
|
||||
return {
|
||||
items: matched.slice(query.skip, query.skip + query.take).map(serializeMemoryWecomContact),
|
||||
total: matched.length,
|
||||
take: query.take,
|
||||
skip: query.skip,
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/api/admin/wecom/contacts/:id", async (request, reply) => {
|
||||
const { id } = z.object({ id: z.string().min(1) }).parse(request.params);
|
||||
const contact = memoryWecomContacts.find((item) => item.id === id);
|
||||
if (!contact) return reply.status(404).send({ message: "企微客户不存在" });
|
||||
return serializeMemoryWecomContact(contact);
|
||||
});
|
||||
|
||||
app.patch("/api/admin/wecom/contacts/:id/link", async (request, reply) => {
|
||||
const { id } = z.object({ id: z.string().min(1) }).parse(request.params);
|
||||
const body = z.object({
|
||||
miniProgramUserId: z.string().min(1).nullable().optional(),
|
||||
leadId: z.string().min(1).nullable().optional(),
|
||||
}).parse(request.body);
|
||||
const contact = memoryWecomContacts.find((item) => item.id === id);
|
||||
if (!contact) return reply.status(404).send({ message: "企微客户不存在" });
|
||||
if (body.miniProgramUserId !== undefined) {
|
||||
if (body.miniProgramUserId && !miniProgramUsers.some((user) => user.id === body.miniProgramUserId)) return reply.status(404).send({ message: "小程序用户不存在" });
|
||||
contact.miniProgramUserId = body.miniProgramUserId;
|
||||
contact.customerId = body.miniProgramUserId ? `memory-customer-${body.miniProgramUserId}` : null;
|
||||
}
|
||||
if (body.leadId !== undefined) {
|
||||
contact.leadIds = body.leadId ? [body.leadId] : [];
|
||||
leads.forEach((lead) => {
|
||||
if (lead.wecomContactId === contact.id) lead.wecomContactId = null;
|
||||
});
|
||||
if (body.leadId) {
|
||||
const lead = leads.find((item) => item.id === body.leadId);
|
||||
if (!lead) return reply.status(404).send({ message: "需求线索不存在" });
|
||||
lead.wecomContactId = contact.id;
|
||||
}
|
||||
}
|
||||
contact.updatedAt = new Date().toISOString();
|
||||
return serializeMemoryWecomContact(contact);
|
||||
});
|
||||
|
||||
app.post("/api/admin/wecom/contacts/:id/refresh", async (_request, reply) => {
|
||||
return reply.status(503).send({ message: "内存开发库不连接企微远程接口" });
|
||||
});
|
||||
|
||||
app.post("/api/public/auth/wechat-login", async (request, reply) => {
|
||||
const body = miniProgramLoginSchema.parse(request.body);
|
||||
const now = new Date().toISOString();
|
||||
@@ -1454,20 +1289,6 @@ export async function registerMemoryRoutes(app: FastifyInstance) {
|
||||
}));
|
||||
});
|
||||
|
||||
app.get("/api/public/wecom/contact-way", async () => {
|
||||
const config = getWecomContactWayConfigDefaults();
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
configId: config.configId,
|
||||
type: config.type,
|
||||
scene: config.scene,
|
||||
style: config.style,
|
||||
remark: config.remark,
|
||||
state: config.state,
|
||||
pluginId: WECOM_CONTACT_PLUGIN_ID,
|
||||
pluginVersion: WECOM_CONTACT_PLUGIN_VERSION,
|
||||
};
|
||||
});
|
||||
app.get("/api/public/site-config", async () => siteConfig(true));
|
||||
app.get("/api/public/products", async () => ({ items: products.filter((product) => product.status === "published").map(productWithDestination) }));
|
||||
app.get("/api/public/products/:id", async (request, reply) => {
|
||||
@@ -1536,81 +1357,4 @@ export async function registerMemoryRoutes(app: FastifyInstance) {
|
||||
return reply.status(201).send({ id: lead.id, status: lead.status });
|
||||
});
|
||||
|
||||
app.get("/api/integrations/wecom/callback", async (request, reply) => {
|
||||
const query = (request.query ?? {}) as Record<string, unknown>;
|
||||
const signature = String(query.msg_signature ?? query.signature ?? "");
|
||||
const timestamp = String(query.timestamp ?? "");
|
||||
const nonce = String(query.nonce ?? "");
|
||||
const echo = String(query.echostr ?? "");
|
||||
if (!hasWecomCallbackCredentials()) return reply.status(503).send({ message: "企微回调尚未配置" });
|
||||
if (!verifyWecomCallback(signature, timestamp, nonce, echo)) return reply.status(401).send({ message: "企微回调签名校验失败" });
|
||||
try {
|
||||
return reply.type("text/plain").send(decryptWecomCallback(echo));
|
||||
} catch {
|
||||
return reply.status(400).send({ message: "企微回调地址校验失败" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/integrations/wecom/callback", async (request, reply) => {
|
||||
const query = (request.query ?? {}) as Record<string, unknown>;
|
||||
const signature = String(query.msg_signature ?? query.signature ?? "");
|
||||
const timestamp = String(query.timestamp ?? "");
|
||||
const nonce = String(query.nonce ?? "");
|
||||
const outerXml = typeof request.body === "string" ? request.body : "";
|
||||
const encrypted = parseWecomXml(outerXml).Encrypt;
|
||||
if (!hasWecomCallbackCredentials()) return reply.status(503).send({ message: "企微回调尚未配置" });
|
||||
if (!encrypted || !verifyWecomCallback(signature, timestamp, nonce, encrypted)) return reply.status(401).send({ message: "企微回调签名校验失败" });
|
||||
try {
|
||||
const event = normalizeWecomCallbackEvent(decryptWecomCallback(encrypted));
|
||||
if (event.externalUserId && event.userId) {
|
||||
const now = event.eventTime.toISOString();
|
||||
const existing = memoryWecomContacts.find((item) => item.externalUserId === event.externalUserId && item.wecomUserId === event.userId);
|
||||
if (event.changeType === "del_external_contact" || event.changeType === "del_follow_user") {
|
||||
if (existing) {
|
||||
existing.status = "deleted";
|
||||
existing.deletedAt = now;
|
||||
existing.lastEventAt = now;
|
||||
existing.updatedAt = now;
|
||||
}
|
||||
} else if (event.changeType === "add_external_contact" || event.changeType === "edit_external_contact") {
|
||||
memoryWecomContactWay.wecomUserId ??= event.userId;
|
||||
if (existing) {
|
||||
existing.status = "active";
|
||||
existing.deletedAt = null;
|
||||
existing.lastEventAt = now;
|
||||
existing.updatedAt = now;
|
||||
} else {
|
||||
memoryWecomContacts.unshift({
|
||||
id: randomUUID(),
|
||||
externalUserId: event.externalUserId,
|
||||
wecomUserId: event.userId,
|
||||
contactWayId: memoryWecomContactWay.id,
|
||||
miniProgramUserId: null,
|
||||
customerId: null,
|
||||
name: null,
|
||||
avatarUrl: null,
|
||||
type: null,
|
||||
gender: null,
|
||||
unionId: null,
|
||||
corpName: null,
|
||||
position: null,
|
||||
remark: null,
|
||||
description: null,
|
||||
profile: null,
|
||||
status: "active",
|
||||
addedAt: now,
|
||||
lastEventAt: now,
|
||||
deletedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
leadIds: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return reply.type("text/plain").send("success");
|
||||
} catch {
|
||||
return reply.status(500).send({ message: "企微回调处理失败" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
miniProgramProfileUpdateSchema,
|
||||
} from "../schemas.js";
|
||||
import { exchangeWechatLoginCode, exchangeWechatPhoneCode, getWechatAppId } from "../wechat.js";
|
||||
import { getWecomContactWayConfigDefaults, WECOM_CONTACT_PLUGIN_ID, WECOM_CONTACT_PLUGIN_VERSION } from "../wecom.js";
|
||||
|
||||
const productQuerySchema = z.object({
|
||||
keyword: z.string().optional(),
|
||||
@@ -365,21 +364,6 @@ export async function registerPublicRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get("/health", async () => ({ ok: true, service: "miniapp-api", mode: "postgres", persistent: true }));
|
||||
|
||||
app.get("/api/public/wecom/contact-way", async () => {
|
||||
const config = getWecomContactWayConfigDefaults();
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
configId: config.configId,
|
||||
type: config.type,
|
||||
scene: config.scene,
|
||||
style: config.style,
|
||||
remark: config.remark,
|
||||
state: config.state,
|
||||
pluginId: WECOM_CONTACT_PLUGIN_ID,
|
||||
pluginVersion: WECOM_CONTACT_PLUGIN_VERSION,
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/api/public/site-config", async () => {
|
||||
const [homeModules, heroSlides, destinations, themes, ctaBanners, campaigns, destinationPageConfig, searchPageConfig] = await Promise.all([
|
||||
listHomeModules(true),
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { prisma } from "../prisma.js";
|
||||
import {
|
||||
decryptWecomCallback,
|
||||
getWecomContactWayConfigDefaults,
|
||||
getWecomExternalContact,
|
||||
hasWecomApiCredentials,
|
||||
hasWecomCallbackCredentials,
|
||||
normalizeWecomCallbackEvent,
|
||||
parseWecomXml,
|
||||
verifyWecomCallback,
|
||||
} from "../wecom.js";
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : String(value ?? "");
|
||||
}
|
||||
|
||||
function callbackQuery(request: { query: unknown }) {
|
||||
const query = (request.query ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
signature: stringValue(query.msg_signature ?? query.signature),
|
||||
timestamp: stringValue(query.timestamp),
|
||||
nonce: stringValue(query.nonce),
|
||||
echo: stringValue(query.echostr),
|
||||
};
|
||||
}
|
||||
|
||||
function callbackXml(request: { body: unknown }) {
|
||||
return typeof request.body === "string" ? request.body : "";
|
||||
}
|
||||
|
||||
async function ensureContactWay(eventUserId: string | null) {
|
||||
const defaults = getWecomContactWayConfigDefaults();
|
||||
return prisma.wecomContactWay.upsert({
|
||||
where: { configId: defaults.configId },
|
||||
update: {
|
||||
wecomUserId: defaults.wecomUserId ?? eventUserId ?? undefined,
|
||||
},
|
||||
create: {
|
||||
...defaults,
|
||||
wecomUserId: eventUserId ?? defaults.wecomUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function consumeWecomEvent(app: FastifyInstance, event: ReturnType<typeof normalizeWecomCallbackEvent>) {
|
||||
if (!event.externalUserId || !event.userId) return { handled: false, reason: "missing_external_contact_identity" };
|
||||
const changeType = event.changeType;
|
||||
const existing = await prisma.wecomExternalContact.findUnique({
|
||||
where: { externalUserId_wecomUserId: { externalUserId: event.externalUserId, wecomUserId: event.userId } },
|
||||
});
|
||||
|
||||
if (changeType === "del_external_contact" || changeType === "del_follow_user") {
|
||||
if (!existing) return { handled: true, status: "deleted", created: false };
|
||||
const deleted = await prisma.wecomExternalContact.update({
|
||||
where: { id: existing.id },
|
||||
data: { status: "deleted", deletedAt: event.eventTime, lastEventAt: event.eventTime },
|
||||
});
|
||||
return { handled: true, status: deleted.status, id: deleted.id, created: false };
|
||||
}
|
||||
|
||||
if (changeType !== "add_external_contact" && changeType !== "edit_external_contact") {
|
||||
return { handled: false, reason: `unsupported_change_type:${changeType ?? "unknown"}` };
|
||||
}
|
||||
|
||||
const contactWay = await ensureContactWay(event.userId);
|
||||
let detail: Awaited<ReturnType<typeof getWecomExternalContact>> | null = null;
|
||||
if (hasWecomApiCredentials()) {
|
||||
try {
|
||||
detail = await getWecomExternalContact(event.externalUserId, event.userId);
|
||||
} catch (error) {
|
||||
app.log.warn({ error }, "企微外部联系人详情获取失败,将保留回调基础信息");
|
||||
}
|
||||
}
|
||||
|
||||
const external = detail?.external_contact;
|
||||
const follow = detail?.follow_info;
|
||||
const unionId = external?.unionid ?? null;
|
||||
const linkedUser = unionId
|
||||
? await prisma.miniProgramUser.findFirst({ where: { unionId }, select: { id: true, customerId: true } })
|
||||
: null;
|
||||
const profile = external?.external_profile;
|
||||
const profileJson = profile ? (profile as Prisma.InputJsonValue) : undefined;
|
||||
const contact = await prisma.wecomExternalContact.upsert({
|
||||
where: { externalUserId_wecomUserId: { externalUserId: event.externalUserId, wecomUserId: event.userId } },
|
||||
update: {
|
||||
contactWayId: contactWay.id,
|
||||
name: external?.name ?? existing?.name ?? null,
|
||||
avatarUrl: external?.avatar ?? existing?.avatarUrl ?? null,
|
||||
type: external?.type ?? existing?.type ?? null,
|
||||
gender: external?.gender ?? existing?.gender ?? null,
|
||||
unionId: unionId ?? existing?.unionId ?? null,
|
||||
corpName: external?.corp_name ?? existing?.corpName ?? null,
|
||||
position: external?.position ?? existing?.position ?? null,
|
||||
remark: follow?.remark ?? existing?.remark ?? null,
|
||||
description: follow?.description ?? existing?.description ?? null,
|
||||
profile: profileJson ?? existing?.profile ?? undefined,
|
||||
status: "active",
|
||||
addedAt: follow?.createtime ? new Date(follow.createtime * 1000) : existing?.addedAt ?? event.eventTime,
|
||||
deletedAt: null,
|
||||
lastEventAt: event.eventTime,
|
||||
miniProgramUserId: linkedUser?.id ?? existing?.miniProgramUserId ?? null,
|
||||
customerId: linkedUser?.customerId ?? existing?.customerId ?? null,
|
||||
},
|
||||
create: {
|
||||
externalUserId: event.externalUserId,
|
||||
wecomUserId: event.userId,
|
||||
contactWayId: contactWay.id,
|
||||
name: external?.name ?? null,
|
||||
avatarUrl: external?.avatar ?? null,
|
||||
type: external?.type ?? null,
|
||||
gender: external?.gender ?? null,
|
||||
unionId,
|
||||
corpName: external?.corp_name ?? null,
|
||||
position: external?.position ?? null,
|
||||
remark: follow?.remark ?? null,
|
||||
description: follow?.description ?? null,
|
||||
profile: profileJson,
|
||||
status: "active",
|
||||
addedAt: follow?.createtime ? new Date(follow.createtime * 1000) : event.eventTime,
|
||||
lastEventAt: event.eventTime,
|
||||
miniProgramUserId: linkedUser?.id ?? null,
|
||||
customerId: linkedUser?.customerId ?? null,
|
||||
},
|
||||
});
|
||||
return { handled: true, status: contact.status, id: contact.id, created: !existing, linkedUserId: contact.miniProgramUserId };
|
||||
}
|
||||
|
||||
export async function registerWecomRoutes(app: FastifyInstance) {
|
||||
app.get("/api/integrations/wecom/callback", async (request, reply) => {
|
||||
const query = callbackQuery(request);
|
||||
if (!hasWecomCallbackCredentials()) return reply.status(503).send({ message: "企微回调尚未配置" });
|
||||
if (!verifyWecomCallback(query.signature, query.timestamp, query.nonce, query.echo)) return reply.status(401).send({ message: "企微回调签名校验失败" });
|
||||
try {
|
||||
const plaintext = decryptWecomCallback(query.echo);
|
||||
return reply.type("text/plain").send(plaintext);
|
||||
} catch (error) {
|
||||
app.log.warn({ error }, "企微回调地址校验解密失败");
|
||||
return reply.status(400).send({ message: "企微回调地址校验失败" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/integrations/wecom/callback", async (request, reply) => {
|
||||
const query = callbackQuery(request);
|
||||
const outerXml = callbackXml(request);
|
||||
if (!hasWecomCallbackCredentials()) return reply.status(503).send({ message: "企微回调尚未配置" });
|
||||
const outer = parseWecomXml(outerXml);
|
||||
const encrypted = outer.Encrypt;
|
||||
if (!encrypted || !verifyWecomCallback(query.signature, query.timestamp, query.nonce, encrypted)) {
|
||||
return reply.status(401).send({ message: "企微回调签名校验失败" });
|
||||
}
|
||||
|
||||
try {
|
||||
const event = normalizeWecomCallbackEvent(decryptWecomCallback(encrypted));
|
||||
const result = await consumeWecomEvent(app, event);
|
||||
app.log.info({ infoType: event.infoType, changeType: event.changeType, result }, "企微客户联系回调已处理");
|
||||
return reply.type("text/plain").send("success");
|
||||
} catch (error) {
|
||||
app.log.error({ error }, "企微客户联系回调处理失败");
|
||||
return reply.status(500).send({ message: "企微回调处理失败" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncWecomContactEventForTests(app: FastifyInstance, xml: string) {
|
||||
return consumeWecomEvent(app, normalizeWecomCallbackEvent(xml));
|
||||
}
|
||||
@@ -248,26 +248,3 @@ export const searchPageConfigSchema = z.object({
|
||||
popularKeywords: z.array(searchPageKeywordSchema).max(12),
|
||||
groups: z.array(searchPageGroupSchema).max(12),
|
||||
});
|
||||
|
||||
export const wecomContactWayPatchSchema = z.object({
|
||||
configId: z.string().trim().min(1).max(64),
|
||||
type: z.number().int().min(1).max(2).optional(),
|
||||
scene: z.number().int().min(1).max(2).optional(),
|
||||
style: z.number().int().min(1).max(3).optional(),
|
||||
remark: z.string().trim().max(30).nullable().optional(),
|
||||
state: z.string().trim().max(30).nullable().optional(),
|
||||
wecomUserId: z.string().trim().max(128).nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const wecomContactQuerySchema = z.object({
|
||||
keyword: z.string().trim().optional(),
|
||||
status: z.string().trim().optional(),
|
||||
take: z.coerce.number().int().min(1).max(200).default(100),
|
||||
skip: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export const wecomContactLinkSchema = z.object({
|
||||
miniProgramUserId: z.string().uuid().nullable().optional(),
|
||||
leadId: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import { prisma } from "./prisma.js";
|
||||
import { registerAdminRoutes } from "./routes/admin.js";
|
||||
import { registerMemoryRoutes } from "./routes/memory.js";
|
||||
import { registerPublicRoutes } from "./routes/public.js";
|
||||
import { registerWecomRoutes } from "./routes/wecom.js";
|
||||
|
||||
export async function createServer() {
|
||||
const app = Fastify({
|
||||
@@ -22,10 +21,6 @@ export async function createServer() {
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.addContentTypeParser(["application/xml", "text/xml"], { parseAs: "string" }, (_request, body, done) => {
|
||||
done(null, body);
|
||||
});
|
||||
|
||||
await app.register(jwt, {
|
||||
secret: process.env.JWT_SECRET ?? "dev-only-change-me-before-production",
|
||||
});
|
||||
@@ -73,7 +68,6 @@ export async function createServer() {
|
||||
});
|
||||
await registerPublicRoutes(app);
|
||||
await registerAdminRoutes(app);
|
||||
await registerWecomRoutes(app);
|
||||
}
|
||||
|
||||
return app;
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
|
||||
export const DEFAULT_WECOM_CONTACT_WAY_CONFIG_ID = "5f8a2d2756e2c69776c186ef28fc48ee";
|
||||
export const WECOM_CONTACT_PLUGIN_ID = "wx104a1a20c3f81ec2";
|
||||
export const WECOM_CONTACT_PLUGIN_VERSION = "1.4.3";
|
||||
|
||||
export type WecomContactWayConfig = {
|
||||
configId: string;
|
||||
type: number;
|
||||
scene: number;
|
||||
style: number;
|
||||
remark: string | null;
|
||||
state: string | null;
|
||||
wecomUserId: string | null;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type WecomApiResponse = {
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type WecomAccessTokenResponse = WecomApiResponse & { access_token?: string; expires_in?: number };
|
||||
|
||||
export type WecomCallbackEvent = {
|
||||
infoType: string | null;
|
||||
changeType: string | null;
|
||||
userId: string | null;
|
||||
externalUserId: string | null;
|
||||
state: string | null;
|
||||
authCorpId: string | null;
|
||||
eventTime: Date;
|
||||
raw: Record<string, string>;
|
||||
};
|
||||
|
||||
let accessTokenCache: { token: string; expiresAt: number } | null = null;
|
||||
|
||||
function env(name: string) {
|
||||
return process.env[name]?.trim() || "";
|
||||
}
|
||||
|
||||
export function getWecomContactWayConfigDefaults(): WecomContactWayConfig {
|
||||
return {
|
||||
configId: env("WECOM_CONTACT_WAY_CONFIG_ID") || DEFAULT_WECOM_CONTACT_WAY_CONFIG_ID,
|
||||
type: Number(env("WECOM_CONTACT_WAY_TYPE") || 1),
|
||||
scene: Number(env("WECOM_CONTACT_WAY_SCENE") || 1),
|
||||
style: Number(env("WECOM_CONTACT_WAY_STYLE") || 1),
|
||||
remark: env("WECOM_CONTACT_WAY_REMARK") || null,
|
||||
state: env("WECOM_CONTACT_WAY_STATE") || null,
|
||||
wecomUserId: env("WECOM_CONTACT_WAY_USER_ID") || null,
|
||||
enabled: env("WECOM_CONTACT_WAY_ENABLED") !== "0",
|
||||
};
|
||||
}
|
||||
|
||||
export function hasWecomApiCredentials() {
|
||||
return Boolean(env("WECOM_CORP_ID") && env("WECOM_CORP_SECRET"));
|
||||
}
|
||||
|
||||
export function hasWecomCallbackCredentials() {
|
||||
return Boolean(env("WECOM_CALLBACK_TOKEN") && env("WECOM_CALLBACK_AES_KEY"));
|
||||
}
|
||||
|
||||
function apiUrl(path: string, params: Record<string, string>) {
|
||||
const url = new URL(`https://qyapi.weixin.qq.com${path}`);
|
||||
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
|
||||
return url;
|
||||
}
|
||||
|
||||
async function readWecomJson<T extends WecomApiResponse>(url: URL, init?: RequestInit) {
|
||||
const response = await fetch(url, init);
|
||||
const body = (await response.json()) as T;
|
||||
if (!response.ok) throw new Error(`企微接口请求失败:HTTP ${response.status}`);
|
||||
if (typeof body.errcode === "number" && body.errcode !== 0) {
|
||||
throw new Error(`企微接口失败:${body.errmsg ?? body.errcode}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function getWecomAccessToken(forceRefresh = false) {
|
||||
if (!hasWecomApiCredentials()) throw new Error("企微服务端尚未配置 WECOM_CORP_ID / WECOM_CORP_SECRET");
|
||||
if (!forceRefresh && accessTokenCache && accessTokenCache.expiresAt > Date.now() + 60_000) return accessTokenCache.token;
|
||||
|
||||
const body = await readWecomJson<WecomAccessTokenResponse>(apiUrl("/cgi-bin/gettoken", {
|
||||
corpid: env("WECOM_CORP_ID"),
|
||||
corpsecret: env("WECOM_CORP_SECRET"),
|
||||
}));
|
||||
if (!body.access_token) throw new Error("企微接口未返回 access_token");
|
||||
accessTokenCache = {
|
||||
token: body.access_token,
|
||||
expiresAt: Date.now() + Math.max(60, Number(body.expires_in ?? 7200) - 120) * 1000,
|
||||
};
|
||||
return body.access_token;
|
||||
}
|
||||
|
||||
export async function getWecomContactWay(configId = getWecomContactWayConfigDefaults().configId) {
|
||||
const accessToken = await getWecomAccessToken();
|
||||
return readWecomJson<WecomApiResponse & {
|
||||
config?: {
|
||||
config_id?: string;
|
||||
type?: number;
|
||||
scene?: number;
|
||||
style?: number;
|
||||
remark?: string;
|
||||
state?: string;
|
||||
user?: string;
|
||||
is_mp?: number;
|
||||
};
|
||||
}>(apiUrl("/cgi-bin/externalcontact/get_contact_way", { access_token: accessToken }), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ config_id: configId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWecomExternalContact(externalUserId: string, userId: string) {
|
||||
const accessToken = await getWecomAccessToken();
|
||||
return readWecomJson<WecomApiResponse & {
|
||||
external_contact?: {
|
||||
external_userid?: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
type?: number;
|
||||
gender?: number;
|
||||
unionid?: string;
|
||||
position?: string;
|
||||
corp_name?: string;
|
||||
external_profile?: Record<string, unknown>;
|
||||
};
|
||||
follow_info?: {
|
||||
userid?: string;
|
||||
remark?: string;
|
||||
description?: string;
|
||||
createtime?: number;
|
||||
state?: string;
|
||||
remark_corp_name?: string;
|
||||
};
|
||||
}>(apiUrl("/cgi-bin/externalcontact/get", {
|
||||
access_token: accessToken,
|
||||
external_userid: externalUserId,
|
||||
userid: userId,
|
||||
}));
|
||||
}
|
||||
|
||||
export function sha1Signature(values: string[]) {
|
||||
return createHash("sha1").update(values.sort().join(""), "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function decodeCallbackKey() {
|
||||
const key = env("WECOM_CALLBACK_AES_KEY");
|
||||
if (!key) throw new Error("企微回调尚未配置 WECOM_CALLBACK_AES_KEY");
|
||||
return Buffer.from(`${key}=`, "base64");
|
||||
}
|
||||
|
||||
function decryptCallbackPayload(encrypted: string) {
|
||||
const key = decodeCallbackKey();
|
||||
if (key.length !== 32) throw new Error("WECOM_CALLBACK_AES_KEY 长度不正确");
|
||||
const encryptedBuffer = Buffer.from(encrypted, "base64");
|
||||
const decipher = createDecipheriv("aes-256-cbc", key, key.subarray(0, 16));
|
||||
decipher.setAutoPadding(false);
|
||||
const decrypted = Buffer.concat([decipher.update(encryptedBuffer), decipher.final()]);
|
||||
const pad = decrypted[decrypted.length - 1] ?? 0;
|
||||
if (pad < 1 || pad > 32) throw new Error("企微回调解密填充不正确");
|
||||
const unpadded = decrypted.subarray(0, decrypted.length - pad);
|
||||
if (unpadded.length < 20) throw new Error("企微回调解密内容不完整");
|
||||
const messageLength = unpadded.readUInt32BE(16);
|
||||
const messageStart = 20;
|
||||
return unpadded.subarray(messageStart, messageStart + messageLength).toString("utf8");
|
||||
}
|
||||
|
||||
function encryptCallbackPayload(message: string) {
|
||||
const key = decodeCallbackKey();
|
||||
if (key.length !== 32) throw new Error("WECOM_CALLBACK_AES_KEY 长度不正确");
|
||||
const random = randomBytes(16);
|
||||
const messageBuffer = Buffer.from(message, "utf8");
|
||||
const corpId = Buffer.from(env("WECOM_CORP_ID"), "utf8");
|
||||
const unpadded = Buffer.concat([random, Buffer.alloc(4), messageBuffer, corpId]);
|
||||
unpadded.writeUInt32BE(messageBuffer.length, 16);
|
||||
const blockSize = 32;
|
||||
const remainder = unpadded.length % blockSize;
|
||||
const padLength = remainder === 0 ? blockSize : blockSize - remainder;
|
||||
const padding = Buffer.alloc(padLength, padLength);
|
||||
const cipher = createCipheriv("aes-256-cbc", key, key.subarray(0, 16));
|
||||
return Buffer.concat([cipher.update(Buffer.concat([unpadded, padding])), cipher.final()]).toString("base64");
|
||||
}
|
||||
|
||||
function xmlTag(xml: string, tag: string) {
|
||||
const expression = new RegExp(`<${tag}(?:><!\\[CDATA\\[([\\s\\S]*?)\\]\\]></${tag}>|>([\\s\\S]*?)</${tag}>)`);
|
||||
const match = xml.match(expression);
|
||||
return (match?.[1] ?? match?.[2] ?? "").trim();
|
||||
}
|
||||
|
||||
export function parseWecomXml(xml: string) {
|
||||
const tags = [
|
||||
"ToUserName", "FromUserName", "CreateTime", "MsgType", "Event", "EventKey", "ChangeType",
|
||||
"InfoType", "UserID", "ExternalUserID", "State", "WelcomeCode", "AuthCorpId", "Encrypt",
|
||||
];
|
||||
return Object.fromEntries(tags.map((tag) => [tag, xmlTag(xml, tag)]).filter(([, value]) => value)) as Record<string, string>;
|
||||
}
|
||||
|
||||
export function verifyWecomCallback(signature: string, timestamp: string, nonce: string, encrypted: string) {
|
||||
const token = env("WECOM_CALLBACK_TOKEN");
|
||||
if (!token || !signature || !timestamp || !nonce || !encrypted) return false;
|
||||
return sha1Signature([token, timestamp, nonce, encrypted]) === signature;
|
||||
}
|
||||
|
||||
export function decryptWecomCallback(encrypted: string) {
|
||||
return decryptCallbackPayload(encrypted);
|
||||
}
|
||||
|
||||
export function encryptWecomCallback(message: string) {
|
||||
return encryptCallbackPayload(message);
|
||||
}
|
||||
|
||||
export function normalizeWecomCallbackEvent(xml: string): WecomCallbackEvent {
|
||||
const raw = parseWecomXml(xml);
|
||||
const timestamp = Number(raw.CreateTime ?? 0);
|
||||
return {
|
||||
infoType: raw.InfoType || null,
|
||||
changeType: raw.ChangeType || raw.Event || null,
|
||||
userId: raw.UserID || null,
|
||||
externalUserId: raw.ExternalUserID || null,
|
||||
state: raw.State || raw.EventKey || null,
|
||||
authCorpId: raw.AuthCorpId || raw.ToUserName || null,
|
||||
eventTime: Number.isFinite(timestamp) && timestamp > 0 ? new Date(timestamp * 1000) : new Date(),
|
||||
raw,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user