remove unused WeCom backend integration

This commit is contained in:
inman
2026-07-23 18:44:54 +08:00
parent 4b3734f988
commit a284fbe3f3
11 changed files with 6 additions and 1022 deletions

View File

@@ -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,

View File

@@ -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 };

View File

@@ -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: "企微回调处理失败" });
}
});
}

View File

@@ -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),

View File

@@ -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));
}

View File

@@ -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(),
});

View File

@@ -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;

View File

@@ -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,
};
}