remove unused WeCom backend integration
This commit is contained in:
12
.env.example
12
.env.example
@@ -3,18 +3,6 @@ DATABASE_URL="postgresql://miniapp:miniapp_dev_password@localhost:5433/miniapp?s
|
|||||||
JWT_SECRET="replace-with-a-long-random-secret-before-production"
|
JWT_SECRET="replace-with-a-long-random-secret-before-production"
|
||||||
WECHAT_APP_ID="your-wechat-mini-program-app-id"
|
WECHAT_APP_ID="your-wechat-mini-program-app-id"
|
||||||
WECHAT_APP_SECRET="your-wechat-mini-program-app-secret"
|
WECHAT_APP_SECRET="your-wechat-mini-program-app-secret"
|
||||||
WECOM_CORP_ID="ww_your_corp_id"
|
|
||||||
WECOM_CORP_SECRET="your-wecom-third-party-app-secret"
|
|
||||||
WECOM_CONTACT_WAY_CONFIG_ID="5f8a2d2756e2c69776c186ef28fc48ee"
|
|
||||||
WECOM_CONTACT_WAY_USER_ID="your-wecom-service-manager-userid"
|
|
||||||
WECOM_CONTACT_WAY_TYPE="1"
|
|
||||||
WECOM_CONTACT_WAY_SCENE="1"
|
|
||||||
WECOM_CONTACT_WAY_STYLE="1"
|
|
||||||
WECOM_CONTACT_WAY_REMARK=""
|
|
||||||
WECOM_CONTACT_WAY_STATE=""
|
|
||||||
WECOM_CONTACT_WAY_ENABLED="1"
|
|
||||||
WECOM_CALLBACK_TOKEN="your-wecom-callback-token"
|
|
||||||
WECOM_CALLBACK_AES_KEY="your-wecom-callback-aes-key-without-equals"
|
|
||||||
MINIAPP_MOCK_AUTH="0"
|
MINIAPP_MOCK_AUTH="0"
|
||||||
PORT=4000
|
PORT=4000
|
||||||
MEDIA_PUBLIC_BASE_URL=""
|
MEDIA_PUBLIC_BASE_URL=""
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { createHomeModuleSnapshot, DEFAULT_HOME_MODULES } from "../src/home-modu
|
|||||||
import { collectImageUrls, materializeImageUrls } from "../src/media-migration.ts";
|
import { collectImageUrls, materializeImageUrls } from "../src/media-migration.ts";
|
||||||
import { mediaStorage } from "../src/media-storage.ts";
|
import { mediaStorage } from "../src/media-storage.ts";
|
||||||
import { DEFAULT_SEARCH_PAGE_CONFIG, SEARCH_PAGE_CONFIG_ID } from "../src/search-page.ts";
|
import { DEFAULT_SEARCH_PAGE_CONFIG, SEARCH_PAGE_CONFIG_ID } from "../src/search-page.ts";
|
||||||
import { getWecomContactWayConfigDefaults } from "../src/wecom.ts";
|
|
||||||
|
|
||||||
type SourceProduct = {
|
type SourceProduct = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -115,7 +114,6 @@ async function main() {
|
|||||||
const seededSearchPage = await materializeImageUrls(DEFAULT_SEARCH_PAGE_CONFIG);
|
const seededSearchPage = await materializeImageUrls(DEFAULT_SEARCH_PAGE_CONFIG);
|
||||||
|
|
||||||
await prisma.auditLog.deleteMany();
|
await prisma.auditLog.deleteMany();
|
||||||
await prisma.wecomExternalContact.deleteMany();
|
|
||||||
await prisma.siteVersion.deleteMany();
|
await prisma.siteVersion.deleteMany();
|
||||||
await prisma.homeModule.deleteMany();
|
await prisma.homeModule.deleteMany();
|
||||||
await prisma.searchPageConfig.deleteMany();
|
await prisma.searchPageConfig.deleteMany();
|
||||||
@@ -137,13 +135,6 @@ async function main() {
|
|||||||
await prisma.destination.deleteMany();
|
await prisma.destination.deleteMany();
|
||||||
await prisma.mediaAsset.deleteMany();
|
await prisma.mediaAsset.deleteMany();
|
||||||
|
|
||||||
const wecomDefaults = getWecomContactWayConfigDefaults();
|
|
||||||
await prisma.wecomContactWay.upsert({
|
|
||||||
where: { configId: wecomDefaults.configId },
|
|
||||||
update: {},
|
|
||||||
create: wecomDefaults,
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.homeModule.createMany({
|
await prisma.homeModule.createMany({
|
||||||
data: seededHomeModules.map((module) => ({
|
data: seededHomeModules.map((module) => ({
|
||||||
...module,
|
...module,
|
||||||
|
|||||||
@@ -38,19 +38,7 @@ import {
|
|||||||
productUpdateSchema,
|
productUpdateSchema,
|
||||||
searchPageConfigSchema,
|
searchPageConfigSchema,
|
||||||
storedImageUrlSchema,
|
storedImageUrlSchema,
|
||||||
wecomContactLinkSchema,
|
|
||||||
wecomContactQuerySchema,
|
|
||||||
wecomContactWayPatchSchema,
|
|
||||||
} from "../schemas.js";
|
} from "../schemas.js";
|
||||||
import {
|
|
||||||
getWecomContactWay,
|
|
||||||
getWecomContactWayConfigDefaults,
|
|
||||||
getWecomExternalContact,
|
|
||||||
hasWecomApiCredentials,
|
|
||||||
hasWecomCallbackCredentials,
|
|
||||||
WECOM_CONTACT_PLUGIN_ID,
|
|
||||||
WECOM_CONTACT_PLUGIN_VERSION,
|
|
||||||
} from "../wecom.js";
|
|
||||||
|
|
||||||
type SourceProduct = {
|
type SourceProduct = {
|
||||||
id: number;
|
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) {
|
export async function registerAdminRoutes(app: FastifyInstance) {
|
||||||
app.post("/api/admin/auth/login", async (request, reply) => {
|
app.post("/api/admin/auth/login", async (request, reply) => {
|
||||||
const body = loginSchema.parse(request.body);
|
const body = loginSchema.parse(request.body);
|
||||||
@@ -1137,213 +1092,6 @@ export async function registerAdminRoutes(app: FastifyInstance) {
|
|||||||
return { ...lead, phone: lead.phone };
|
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 () => {
|
app.get("/api/admin/media-assets", { preHandler: requireAdmin }, async () => {
|
||||||
const assets = await prisma.mediaAsset.findMany({ orderBy: { createdAt: "desc" }, take: 200 });
|
const assets = await prisma.mediaAsset.findMany({ orderBy: { createdAt: "desc" }, take: 200 });
|
||||||
return { items: assets };
|
return { items: assets };
|
||||||
|
|||||||
@@ -47,20 +47,7 @@ import {
|
|||||||
productUpdateSchema,
|
productUpdateSchema,
|
||||||
searchPageConfigSchema,
|
searchPageConfigSchema,
|
||||||
storedImageUrlSchema,
|
storedImageUrlSchema,
|
||||||
wecomContactQuerySchema,
|
|
||||||
wecomContactWayPatchSchema,
|
|
||||||
} from "../schemas.js";
|
} from "../schemas.js";
|
||||||
import {
|
|
||||||
decryptWecomCallback,
|
|
||||||
getWecomContactWayConfigDefaults,
|
|
||||||
hasWecomApiCredentials,
|
|
||||||
hasWecomCallbackCredentials,
|
|
||||||
normalizeWecomCallbackEvent,
|
|
||||||
parseWecomXml,
|
|
||||||
verifyWecomCallback,
|
|
||||||
WECOM_CONTACT_PLUGIN_ID,
|
|
||||||
WECOM_CONTACT_PLUGIN_VERSION,
|
|
||||||
} from "../wecom.js";
|
|
||||||
|
|
||||||
type MemoryHomeModule = {
|
type MemoryHomeModule = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -170,7 +157,6 @@ type MemoryLead = {
|
|||||||
id: string;
|
id: string;
|
||||||
requestType: "custom" | "booking";
|
requestType: "custom" | "booking";
|
||||||
userId?: string | null;
|
userId?: string | null;
|
||||||
wecomContactId?: string | null;
|
|
||||||
destination?: string | null;
|
destination?: string | null;
|
||||||
phone: string;
|
phone: string;
|
||||||
contactName?: string | null;
|
contactName?: string | null;
|
||||||
@@ -236,47 +222,6 @@ type MemoryHistory = {
|
|||||||
lastViewedAt: string;
|
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 = {
|
type SourceProduct = {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -519,14 +464,6 @@ const mediaAssets: Array<{ id: string; url: string; name: string; mimeType: stri
|
|||||||
const miniProgramUsers: MemoryMiniProgramUser[] = [];
|
const miniProgramUsers: MemoryMiniProgramUser[] = [];
|
||||||
const memoryFavorites: MemoryFavorite[] = [];
|
const memoryFavorites: MemoryFavorite[] = [];
|
||||||
const memoryHistories: MemoryHistory[] = [];
|
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) {
|
function memoryMaskPhone(phone?: string | null) {
|
||||||
if (!phone) return 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) {
|
function findMemoryProduct(id: string) {
|
||||||
return products.find((product) => product.id === id || String(product.sourceId) === id);
|
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 };
|
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) => {
|
app.post("/api/public/auth/wechat-login", async (request, reply) => {
|
||||||
const body = miniProgramLoginSchema.parse(request.body);
|
const body = miniProgramLoginSchema.parse(request.body);
|
||||||
const now = new Date().toISOString();
|
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/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", async () => ({ items: products.filter((product) => product.status === "published").map(productWithDestination) }));
|
||||||
app.get("/api/public/products/:id", async (request, reply) => {
|
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 });
|
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,
|
miniProgramProfileUpdateSchema,
|
||||||
} from "../schemas.js";
|
} from "../schemas.js";
|
||||||
import { exchangeWechatLoginCode, exchangeWechatPhoneCode, getWechatAppId } from "../wechat.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({
|
const productQuerySchema = z.object({
|
||||||
keyword: z.string().optional(),
|
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("/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 () => {
|
app.get("/api/public/site-config", async () => {
|
||||||
const [homeModules, heroSlides, destinations, themes, ctaBanners, campaigns, destinationPageConfig, searchPageConfig] = await Promise.all([
|
const [homeModules, heroSlides, destinations, themes, ctaBanners, campaigns, destinationPageConfig, searchPageConfig] = await Promise.all([
|
||||||
listHomeModules(true),
|
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),
|
popularKeywords: z.array(searchPageKeywordSchema).max(12),
|
||||||
groups: z.array(searchPageGroupSchema).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 { registerAdminRoutes } from "./routes/admin.js";
|
||||||
import { registerMemoryRoutes } from "./routes/memory.js";
|
import { registerMemoryRoutes } from "./routes/memory.js";
|
||||||
import { registerPublicRoutes } from "./routes/public.js";
|
import { registerPublicRoutes } from "./routes/public.js";
|
||||||
import { registerWecomRoutes } from "./routes/wecom.js";
|
|
||||||
|
|
||||||
export async function createServer() {
|
export async function createServer() {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@@ -22,10 +21,6 @@ export async function createServer() {
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.addContentTypeParser(["application/xml", "text/xml"], { parseAs: "string" }, (_request, body, done) => {
|
|
||||||
done(null, body);
|
|
||||||
});
|
|
||||||
|
|
||||||
await app.register(jwt, {
|
await app.register(jwt, {
|
||||||
secret: process.env.JWT_SECRET ?? "dev-only-change-me-before-production",
|
secret: process.env.JWT_SECRET ?? "dev-only-change-me-before-production",
|
||||||
});
|
});
|
||||||
@@ -73,7 +68,6 @@ export async function createServer() {
|
|||||||
});
|
});
|
||||||
await registerPublicRoutes(app);
|
await registerPublicRoutes(app);
|
||||||
await registerAdminRoutes(app);
|
await registerAdminRoutes(app);
|
||||||
await registerWecomRoutes(app);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
这是唯一的用户端前台,基于 Taro + React + TypeScript,覆盖探索、搜索、线路详情、收藏、最近浏览、微信登录、手机号授权、我的需求和原生企微“联系我”页。
|
这是唯一的用户端前台,基于 Taro + React + TypeScript,覆盖探索、搜索、线路详情、收藏、最近浏览、微信登录、手机号授权、我的需求和原生企微“联系我”页。
|
||||||
|
|
||||||
小程序已声明企微官方插件 `wx104a1a20c3f81ec2`(版本 `1.4.3`)。`pages/contact/index` 通过公开 API 读取当前联系我 `config_id`,再渲染 `plugin://contactPlugin/cell`;H5 中的“企微联系”入口只负责跳转到该原生页。
|
小程序已声明企微官方插件 `wx104a1a20c3f81ec2`(版本 `1.4.3`)。`pages/contact/index` 内置企业微信后台创建的联系我 `config_id`,直接渲染 `plugin://contactPlugin/cell`;H5 中的“企微联系”入口只负责跳转到该原生页。后端不参与联系我插件的配置、同步或回调。
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
@@ -30,6 +30,6 @@ TARO_APP_API_BASE_URL="https://biz.wanderqtrip.com/api"
|
|||||||
npm run build:miniapp
|
npm run build:miniapp
|
||||||
```
|
```
|
||||||
|
|
||||||
生产 API 需要配置 `WECHAT_APP_ID`、`WECHAT_APP_SECRET`、`DATABASE_URL` 和 `JWT_SECRET`,企微对接还需要按 `docs/backend/README.md` 配置 `WECOM_*` 服务端变量,并将 API 域名与 OSS/CDN 域名加入微信小程序的 `request`、`downloadFile` 合法域名。图片统一使用 API 返回的 OSS WebP 公共 URL,不打包本地图片素材。
|
生产 API 需要配置 `WECHAT_APP_ID`、`WECHAT_APP_SECRET`、`DATABASE_URL` 和 `JWT_SECRET`,并将 API 域名与 OSS/CDN 域名加入微信小程序的 `request`、`downloadFile` 合法域名。联系我插件的 `config_id` 已随小程序代码发布,不需要后端企微密钥。图片统一使用 API 返回的 OSS WebP 公共 URL,不打包本地图片素材。
|
||||||
|
|
||||||
数据库首次部署执行 `npm run db:migrate`,初始化内容执行 `npm run db:seed`。
|
数据库首次部署执行 `npm run db:migrate`,初始化内容执行 `npm run db:seed`。
|
||||||
|
|||||||
@@ -1,72 +1,26 @@
|
|||||||
import { Button, Text, View } from "@tarojs/components";
|
import { Button, Text, View } from "@tarojs/components";
|
||||||
import Taro from "@tarojs/taro";
|
import Taro from "@tarojs/taro";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { apiUrl } from "../../services/api";
|
|
||||||
|
|
||||||
const SUPPORT_PHONE = "18786174929";
|
const SUPPORT_PHONE = "18786174929";
|
||||||
|
const CONTACT_WAY_CONFIG_ID = "5f8a2d2756e2c69776c186ef28fc48ee";
|
||||||
type ContactWayResponse = {
|
const CONTACT_WAY_STYLE = 1;
|
||||||
enabled: boolean;
|
|
||||||
configId: string;
|
|
||||||
type: number;
|
|
||||||
scene: number;
|
|
||||||
style: number;
|
|
||||||
remark?: string | null;
|
|
||||||
state?: string | null;
|
|
||||||
pluginId: string;
|
|
||||||
pluginVersion: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_CONTACT_WAY: ContactWayResponse = {
|
|
||||||
enabled: true,
|
|
||||||
configId: "5f8a2d2756e2c69776c186ef28fc48ee",
|
|
||||||
type: 1,
|
|
||||||
scene: 1,
|
|
||||||
style: 1,
|
|
||||||
remark: null,
|
|
||||||
state: null,
|
|
||||||
pluginId: "wx104a1a20c3f81ec2",
|
|
||||||
pluginVersion: "1.4.3",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
const [contactWay, setContactWay] = useState<ContactWayResponse>(DEFAULT_CONTACT_WAY);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
Taro.request<ContactWayResponse>({ url: apiUrl("/api/public/wecom/contact-way") })
|
|
||||||
.then((response) => {
|
|
||||||
if (!active) return;
|
|
||||||
if (response.statusCode >= 200 && response.statusCode < 300 && response.data?.configId) {
|
|
||||||
setContactWay(response.data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (active) console.warn("企微联系配置接口暂时不可达,使用内置联系我配置", DEFAULT_CONTACT_WAY.configId);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const callSupport = () => {
|
const callSupport = () => {
|
||||||
void Taro.makePhoneCall({ phoneNumber: SUPPORT_PHONE });
|
void Taro.makePhoneCall({ phoneNumber: SUPPORT_PHONE });
|
||||||
};
|
};
|
||||||
|
|
||||||
const showPlugin = Boolean(contactWay.enabled && contactWay.configId);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="page contact-page">
|
<View className="page contact-page">
|
||||||
<View className="contact-hero">
|
<View className="contact-hero">
|
||||||
<Text className="eyebrow">联系万趣定制管家</Text>
|
<Text className="eyebrow">联系万趣定制管家</Text>
|
||||||
<Text className="contact-title">联系万趣定制管家</Text>
|
<Text className="contact-title">联系万趣定制管家</Text>
|
||||||
</View>
|
</View>
|
||||||
{showPlugin ? (
|
{CONTACT_WAY_CONFIG_ID ? (
|
||||||
<View className="contact-plugin-card">
|
<View className="contact-plugin-card">
|
||||||
<Text className="contact-card-title">添加企微服务管家</Text>
|
<Text className="contact-card-title">添加企微服务管家</Text>
|
||||||
<Text className="contact-card-note">点击按钮后将进入企微添加流程</Text>
|
<Text className="contact-card-note">点击按钮后将进入企微添加流程</Text>
|
||||||
<cell plugid={contactWay.configId} styleType={contactWay.style} />
|
<cell plugid={CONTACT_WAY_CONFIG_ID} styleType={CONTACT_WAY_STYLE} />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className="contact-plugin-card contact-fallback">
|
<View className="contact-plugin-card contact-fallback">
|
||||||
|
|||||||
Reference in New Issue
Block a user