v4.0.17企业微信API插件优化

This commit is contained in:
2026-09-06 09:36:49 +08:00
parent 0e711be6c6
commit 192a401227
36 changed files with 2416 additions and 103 deletions
@@ -9,7 +9,7 @@ export type WecomBotAuditContext = {
bot: WecomBotRuntimeConfig;
wecomUserId: string;
action: string;
permission?: WecomBotPermission | null;
permission?: WecomBotPermission | string | null;
inputSummary?: string | null;
};
@@ -0,0 +1,59 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
WECOM_PLUGIN_PERMISSIONS,
parseWecomPluginPermissions,
type WecomPluginPermission,
} from '@dukang/shared-types';
import { isWecomPluginEnabled, matchWecomPluginByApiKey } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import type { WecomPluginRuntime } from './wecom-plugin.types';
@Injectable()
export class WecomPluginAuthService implements OnModuleInit {
private readonly logger = new Logger(WecomPluginAuthService.name);
constructor(private readonly prisma: PrismaService) {}
async onModuleInit() {
await this.migrateFromEnv().catch((e) => {
this.logger.warn(
`wecom plugin env migrate skipped: ${e instanceof Error ? e.message : String(e)}`,
);
});
}
async resolveByApiKey(provided: string): Promise<WecomPluginRuntime | null> {
const key = String(provided ?? '').trim();
if (!key) return null;
const rows = await this.prisma.wecomApiPlugin.findMany({
where: { enabled: true },
select: { id: true, name: true, apiKey: true, permissions: true },
});
const hit = matchWecomPluginByApiKey(key, rows);
if (!hit) return null;
return {
id: hit.id.toString(),
name: hit.name,
permissions: parseWecomPluginPermissions(hit.permissions),
};
}
private async migrateFromEnv() {
if (!isWecomPluginEnabled(process.env)) return;
const count = await this.prisma.wecomApiPlugin.count();
if (count > 0) return;
const apiKey = String(process.env.WECOM_PLUGIN_API_KEY ?? '').trim();
if (!apiKey) return;
await this.prisma.wecomApiPlugin.create({
data: {
name: '迁移自 env',
apiKey,
permissions: JSON.stringify([...WECOM_PLUGIN_PERMISSIONS] as WecomPluginPermission[]),
remark: '由 WECOM_PLUGIN_API_KEY 一次性导入',
enabled: true,
sortOrder: 0,
},
});
this.logger.log('wecom api plugin migrated from env');
}
}
@@ -1,19 +1,29 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import {
clampWecomPluginPage,
clampWecomPluginPageSize,
maskContactPhone,
MOBILE_PHONE_RE,
parseWecomPluginMetricsKind,
parseWecomPluginDateRange,
toWecomPluginMetricsView,
toWecomPluginUserView,
wecomPluginMetricsPeriod,
type WecomReportStats,
} from '@dukang/domain';
import {
WECOM_PLUGIN_TOOL_PATHS,
wecomPluginHasPermission,
STORE_INFO_CHANGEABLE_FIELD_LABELS,
type StoreInfoChangeableField,
type WecomPluginPermission,
} from '@dukang/shared-types';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { PromoCodeService } from '../../modules/promo/promo-code.service';
import { WecomBotAuditService } from './wecom-bot-audit.service';
import { WECOM_PLUGIN_AUDIT_BOT } from './wecom-plugin.constants';
import { wecomPluginAuditBot, type WecomPluginRuntime } from './wecom-plugin.types';
import { filterWecomPluginOpenApi, WECOM_PLUGIN_OPENAPI } from './wecom-plugin.openapi';
function asNumber(v: Prisma.Decimal | number | null | undefined): number {
if (v == null) return 0;
@@ -35,22 +45,40 @@ export class WecomPluginQueryService {
private readonly audit: WecomBotAuditService,
) {}
info() {
info(plugin: WecomPluginRuntime) {
return {
name: '杜康好客运营查询',
description: '企微智能机器人只读 API 插件。查询订单、用户、门店、核销、推广码经营指标。',
name: plugin.name,
description: '企微智能机器人只读 API 插件。查询订单、用户、门店经营、核销、推广码经营指标、审核对照与合伙人关联数据。',
auth: { header: 'X-Api-Key' },
tools: ['orders', 'users', 'stores', 'redeems', 'promo-codes', 'metrics'],
permissions: plugin.permissions,
tools: plugin.permissions.flatMap((p) => WECOM_PLUGIN_TOOL_PATHS[p]),
};
}
queryOrders(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
openapi(plugin: WecomPluginRuntime) {
return filterWecomPluginOpenApi(WECOM_PLUGIN_OPENAPI, plugin.permissions, plugin.name);
}
private requirePerm(plugin: WecomPluginRuntime, permission: WecomPluginPermission) {
if (!wecomPluginHasPermission(plugin.permissions, permission)) {
throw new ForbiddenException(`当前插件无权限:${permission}`);
}
}
queryOrders(
plugin: WecomPluginRuntime,
wecomUserId: string,
q?: string,
page?: string,
pageSize?: string,
) {
this.requirePerm(plugin, 'order.read');
const keyword = requireQuery(q);
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
return this.audit.run(
{
bot: WECOM_PLUGIN_AUDIT_BOT,
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.order.read',
permission: 'order.read',
@@ -96,13 +124,20 @@ export class WecomPluginQueryService {
);
}
queryUsers(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
queryUsers(
plugin: WecomPluginRuntime,
wecomUserId: string,
q?: string,
page?: string,
pageSize?: string,
) {
this.requirePerm(plugin, 'user.read');
const keyword = requireQuery(q);
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
return this.audit.run(
{
bot: WECOM_PLUGIN_AUDIT_BOT,
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.user.read',
permission: 'user.read',
@@ -152,13 +187,20 @@ export class WecomPluginQueryService {
);
}
queryStores(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
queryStores(
plugin: WecomPluginRuntime,
wecomUserId: string,
q?: string,
page?: string,
pageSize?: string,
) {
this.requirePerm(plugin, 'store.read');
const keyword = requireQuery(q);
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
return this.audit.run(
{
bot: WECOM_PLUGIN_AUDIT_BOT,
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.store.read',
permission: 'store.read',
@@ -173,39 +215,71 @@ export class WecomPluginQueryService {
skip,
take,
select: {
id: true,
name: true,
status: true,
auditStatus: true,
cityName: true,
district: true,
address: true,
contactPhone: true,
phone: true,
rating: true,
createdAt: true,
partnerAccount: { select: { id: true, name: true, companyName: true } },
_count: { select: { redeemRecords: true } },
},
}),
this.prisma.store.count({ where }),
]);
const redeemSums =
rows.length > 0
? await this.prisma.redeemRecord.groupBy({
by: ['storeId'],
where: { storeId: { in: rows.map((s) => s.id) } },
_sum: { amount: true },
})
: [];
const redeemedByStore = new Map(
redeemSums.map((r) => [r.storeId.toString(), asNumber(r._sum.amount)]),
);
return {
total,
items: rows.map((s) => ({
id: s.id.toString(),
name: s.name,
status: s.status,
auditStatus: s.auditStatus,
cityName: s.cityName,
district: s.district,
address: s.address,
contactPhone: maskContactPhone(s.contactPhone || s.phone),
rating: s.rating != null ? asNumber(s.rating) : null,
redeemCount: s._count.redeemRecords,
totalRedeemedBenefitAmount: redeemedByStore.get(s.id.toString()) ?? 0,
partnerName: s.partnerAccount.companyName || s.partnerAccount.name,
partnerId: s.partnerAccount.id.toString(),
createdAt: s.createdAt.toISOString(),
})),
};
},
);
}
queryRedeems(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
queryRedeems(
plugin: WecomPluginRuntime,
wecomUserId: string,
q?: string,
page?: string,
pageSize?: string,
) {
this.requirePerm(plugin, 'redeem.read');
const keyword = requireQuery(q);
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
return this.audit.run(
{
bot: WECOM_PLUGIN_AUDIT_BOT,
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.redeem.read',
permission: 'redeem.read',
@@ -249,15 +323,23 @@ export class WecomPluginQueryService {
);
}
queryPromoCodes(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
queryPromoCodes(
plugin: WecomPluginRuntime,
wecomUserId: string,
q?: string,
page?: string,
pageSize?: string,
) {
this.requirePerm(plugin, 'promo.read');
const keyword = requireQuery(q);
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
return this.audit.run(
{
bot: WECOM_PLUGIN_AUDIT_BOT,
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.promo.read',
permission: 'promo.read',
inputSummary: keyword,
},
async () => {
@@ -289,13 +371,15 @@ export class WecomPluginQueryService {
);
}
queryPromoCodeStats(wecomUserId: string, code?: string) {
queryPromoCodeStats(plugin: WecomPluginRuntime, wecomUserId: string, code?: string) {
this.requirePerm(plugin, 'promo.read');
const keyword = requireQuery(code);
return this.audit.run(
{
bot: WECOM_PLUGIN_AUDIT_BOT,
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.promo.stats',
permission: 'promo.read',
inputSummary: keyword,
},
async () => {
@@ -315,16 +399,18 @@ export class WecomPluginQueryService {
);
}
queryMetrics(wecomUserId: string, kindRaw?: string) {
queryMetrics(plugin: WecomPluginRuntime, wecomUserId: string, kindRaw?: string) {
this.requirePerm(plugin, 'metrics.read');
const kind = parseWecomPluginMetricsKind(kindRaw);
if (!kind) {
throw new BadRequestException('kind 须为 today | daily | weekly | monthly');
}
return this.audit.run(
{
bot: WECOM_PLUGIN_AUDIT_BOT,
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.metrics.read',
permission: 'metrics.read',
inputSummary: kind,
},
async () => {
@@ -336,7 +422,7 @@ export class WecomPluginQueryService {
rangeLabel: period.rangeLabel,
incrementLabel: period.incrementLabel,
periodKey: period.periodKey,
stats,
stats: toWecomPluginMetricsView(stats),
};
},
);
@@ -415,4 +501,555 @@ export class WecomPluginQueryService {
redeemAmountIncrement: asNumber(redeemAmountIncrement._sum.amount),
};
}
queryStoreAudits(
plugin: WecomPluginRuntime,
wecomUserId: string,
q?: string,
status?: string,
page?: string,
pageSize?: string,
) {
this.requirePerm(plugin, 'store.audit.read');
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
const auditStatus = (status?.trim() || 'PENDING') as 'PENDING' | 'APPROVED' | 'REJECTED';
const where: Prisma.StoreWhereInput = { auditStatus };
const keyword = String(q ?? '').trim();
if (keyword) where.name = { contains: keyword };
return this.audit.run(
{
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.store.audit.list',
permission: 'store.audit.read',
inputSummary: keyword || auditStatus,
},
async () => {
const [rows, total] = await Promise.all([
this.prisma.store.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take,
select: {
id: true,
name: true,
status: true,
auditStatus: true,
rejectReason: true,
cityName: true,
district: true,
address: true,
contactPhone: true,
phone: true,
createdAt: true,
partnerAccount: { select: { id: true, name: true, companyName: true } },
},
}),
this.prisma.store.count({ where }),
]);
return {
total,
items: rows.map((s) => ({
id: s.id.toString(),
name: s.name,
status: s.status,
auditStatus: s.auditStatus,
rejectReason: s.rejectReason,
cityName: s.cityName,
district: s.district,
address: s.address,
contactPhone: maskContactPhone(s.contactPhone || s.phone),
partnerName: s.partnerAccount.companyName || s.partnerAccount.name,
partnerId: s.partnerAccount.id.toString(),
createdAt: s.createdAt.toISOString(),
})),
};
},
);
}
queryStoreInfoAudits(
plugin: WecomPluginRuntime,
wecomUserId: string,
status?: string,
page?: string,
pageSize?: string,
) {
this.requirePerm(plugin, 'store.audit.read');
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
const where: Prisma.StoreInfoChangeRequestWhereInput = {};
if (status?.trim()) {
where.status = status.trim() as Prisma.EnumStoreInfoChangeStatusFilter['equals'];
} else {
where.status = 'PENDING';
}
return this.audit.run(
{
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.store.info_audit.list',
permission: 'store.audit.read',
inputSummary: where.status as string,
},
async () => {
const [rows, total] = await Promise.all([
this.prisma.storeInfoChangeRequest.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take,
include: { store: { select: { name: true } } },
}),
this.prisma.storeInfoChangeRequest.count({ where }),
]);
return {
total,
items: rows.map((r) => this.mapStoreInfoAuditRow(r)),
};
},
);
}
queryStoreInfoAuditDetail(plugin: WecomPluginRuntime, wecomUserId: string, id: string) {
this.requirePerm(plugin, 'store.audit.read');
return this.audit.run(
{
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.store.info_audit.detail',
permission: 'store.audit.read',
inputSummary: id,
},
async () => {
const row = await this.prisma.storeInfoChangeRequest.findUnique({
where: { id: BigInt(id) },
include: { store: { select: { name: true } } },
});
if (!row) throw new NotFoundException('信息变更审核不存在');
return this.mapStoreInfoAuditRow(row, true);
},
);
}
queryStorePackageAudits(
plugin: WecomPluginRuntime,
wecomUserId: string,
status?: string,
page?: string,
pageSize?: string,
) {
this.requirePerm(plugin, 'store.audit.read');
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
const where: Prisma.StorePackageChangeRequestWhereInput = {};
if (status?.trim()) {
where.status = status.trim() as Prisma.EnumStorePackageChangeStatusFilter['equals'];
} else {
where.status = 'PENDING';
}
return this.audit.run(
{
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.store.package_audit.list',
permission: 'store.audit.read',
inputSummary: where.status as string,
},
async () => {
const [rows, total] = await Promise.all([
this.prisma.storePackageChangeRequest.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take,
include: { store: { select: { id: true, name: true } } },
}),
this.prisma.storePackageChangeRequest.count({ where }),
]);
return {
total,
items: rows.map((r) => ({
id: r.id.toString(),
storeId: r.storeId.toString(),
storeName: r.store.name,
status: r.status,
packageCount: Array.isArray(r.packagesJson) ? r.packagesJson.length : 0,
submitterType: r.submitterType,
rejectReason: r.rejectReason,
createdAt: r.createdAt.toISOString(),
})),
};
},
);
}
queryStorePackageAuditDetail(plugin: WecomPluginRuntime, wecomUserId: string, id: string) {
this.requirePerm(plugin, 'store.audit.read');
return this.audit.run(
{
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.store.package_audit.detail',
permission: 'store.audit.read',
inputSummary: id,
},
async () => {
const req = await this.prisma.storePackageChangeRequest.findUnique({
where: { id: BigInt(id) },
include: { store: { select: { name: true } } },
});
if (!req) throw new NotFoundException('套餐审核不存在');
const livePackages = await this.prisma.storePackage.findMany({
where: { storeId: req.storeId },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
select: { name: true, price: true, dishes: true, usableTime: true, otherNotes: true },
});
return {
id: req.id.toString(),
storeId: req.storeId.toString(),
storeName: req.store.name,
status: req.status,
proposedPackages: req.packagesJson,
livePackages: livePackages.map((p) => ({
name: p.name,
price: asNumber(p.price),
dishes: p.dishes,
usableTime: p.usableTime,
otherNotes: p.otherNotes,
})),
submitterType: req.submitterType,
rejectReason: req.rejectReason,
reviewedAt: req.reviewedAt?.toISOString() ?? null,
createdAt: req.createdAt.toISOString(),
};
},
);
}
queryPartners(
plugin: WecomPluginRuntime,
wecomUserId: string,
q?: string,
page?: string,
pageSize?: string,
) {
this.requirePerm(plugin, 'partner.read');
const keyword = requireQuery(q);
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
return this.audit.run(
{
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.partner.read',
permission: 'partner.read',
inputSummary: keyword,
},
async () => {
const or: Prisma.PartnerAccountWhereInput[] = [
{ name: { contains: keyword } },
{ companyName: { contains: keyword } },
{ phone: { contains: keyword } },
];
if (/^\d+$/.test(keyword)) or.push({ id: BigInt(keyword) });
const where: Prisma.PartnerAccountWhereInput = { isPrimary: 1, OR: or };
const [rows, total] = await Promise.all([
this.prisma.partnerAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take,
select: {
id: true,
name: true,
companyName: true,
phone: true,
status: true,
city: { select: { name: true } },
_count: { select: { stores: true, assocUsers: true } },
},
}),
this.prisma.partnerAccount.count({ where }),
]);
return {
total,
items: rows.map((p) => ({
id: p.id.toString(),
name: p.name,
companyName: p.companyName,
phone: maskContactPhone(p.phone),
status: p.status,
cityName: p.city?.name ?? null,
storeCount: p._count.stores,
userCount: p._count.assocUsers,
})),
};
},
);
}
queryPartnerUsers(
plugin: WecomPluginRuntime,
wecomUserId: string,
partnerId: string,
from?: string,
to?: string,
page?: string,
pageSize?: string,
) {
this.requirePerm(plugin, 'partner.read');
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
const createdAt = parseWecomPluginDateRange(from, to);
return this.audit.run(
{
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.partner.users',
permission: 'partner.read',
inputSummary: `${partnerId}:${from || ''}-${to || ''}`,
},
async () => {
const primary = await this.resolvePartnerPrimary(partnerId);
const where: Prisma.UserWhereInput = {
assocPartnerAccountId: primary.id,
mergedIntoUserId: null,
...(createdAt ? { createdAt } : {}),
};
const [rows, total] = await Promise.all([
this.prisma.user.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take,
select: {
userNo: true,
nickname: true,
phone: true,
status: true,
createdAt: true,
_count: { select: { orders: true } },
},
}),
this.prisma.user.count({ where }),
]);
return {
partnerId: primary.id.toString(),
partnerName: primary.companyName || primary.name,
total,
items: rows.map((u) => ({
...toWecomPluginUserView(u),
status: u.status,
orderCount: u._count.orders,
createdAt: u.createdAt.toISOString(),
})),
};
},
);
}
queryPartnerStores(
plugin: WecomPluginRuntime,
wecomUserId: string,
partnerId: string,
from?: string,
to?: string,
page?: string,
pageSize?: string,
) {
this.requirePerm(plugin, 'partner.read');
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
const createdAt = parseWecomPluginDateRange(from, to);
return this.audit.run(
{
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.partner.stores',
permission: 'partner.read',
inputSummary: `${partnerId}:${from || ''}-${to || ''}`,
},
async () => {
const primary = await this.resolvePartnerPrimary(partnerId);
const where: Prisma.StoreWhereInput = {
partnerAccountId: primary.id,
...(createdAt ? { createdAt } : {}),
};
const [rows, total] = await Promise.all([
this.prisma.store.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take,
select: {
id: true,
name: true,
status: true,
auditStatus: true,
cityName: true,
rating: true,
createdAt: true,
_count: { select: { redeemRecords: true } },
},
}),
this.prisma.store.count({ where }),
]);
const redeemSums =
rows.length > 0
? await this.prisma.redeemRecord.groupBy({
by: ['storeId'],
where: { storeId: { in: rows.map((s) => s.id) } },
_sum: { amount: true },
})
: [];
const redeemedByStore = new Map(
redeemSums.map((r) => [r.storeId.toString(), asNumber(r._sum.amount)]),
);
return {
partnerId: primary.id.toString(),
partnerName: primary.companyName || primary.name,
total,
items: rows.map((s) => ({
id: s.id.toString(),
name: s.name,
status: s.status,
auditStatus: s.auditStatus,
cityName: s.cityName,
rating: s.rating != null ? asNumber(s.rating) : null,
redeemCount: s._count.redeemRecords,
totalRedeemedBenefitAmount: redeemedByStore.get(s.id.toString()) ?? 0,
createdAt: s.createdAt.toISOString(),
})),
};
},
);
}
queryPartnerOrders(
plugin: WecomPluginRuntime,
wecomUserId: string,
partnerId: string,
from?: string,
to?: string,
page?: string,
pageSize?: string,
) {
this.requirePerm(plugin, 'partner.read');
const take = clampWecomPluginPageSize(pageSize);
const skip = (clampWecomPluginPage(page) - 1) * take;
const createdAt = parseWecomPluginDateRange(from, to);
return this.audit.run(
{
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.partner.orders',
permission: 'partner.read',
inputSummary: `${partnerId}:${from || ''}-${to || ''}`,
},
async () => {
const primary = await this.resolvePartnerPrimary(partnerId);
const where: Prisma.OrderWhereInput = {
partnerAccountIdAtPay: primary.id,
...(createdAt ? { createdAt } : {}),
};
const [rows, total] = await Promise.all([
this.prisma.order.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take,
select: {
orderNo: true,
status: true,
payStatus: true,
productName: true,
quantity: true,
payAmount: true,
createdAt: true,
user: { select: { userNo: true, nickname: true, phone: true } },
},
}),
this.prisma.order.count({ where }),
]);
return {
partnerId: primary.id.toString(),
partnerName: primary.companyName || primary.name,
total,
items: rows.map((o) => ({
orderNo: o.orderNo,
status: o.status,
payStatus: o.payStatus,
productName: o.productName,
quantity: o.quantity,
payAmount: asNumber(o.payAmount),
user: toWecomPluginUserView({
userNo: o.user?.userNo || '—',
nickname: o.user?.nickname,
phone: o.user?.phone,
}),
createdAt: o.createdAt.toISOString(),
})),
};
},
);
}
private mapStoreInfoAuditRow(
row: {
id: bigint;
storeId: bigint;
status: string;
liveSnapshot: unknown;
proposedSnapshot: unknown;
changedFields: unknown;
submitterType: string;
submitterId: bigint;
rejectReason: string | null;
reviewedAt: Date | null;
createdAt: Date;
store?: { name?: string | null };
},
withDiffs = false,
) {
const changed = Array.isArray(row.changedFields)
? (row.changedFields as StoreInfoChangeableField[])
: [];
const live = (row.liveSnapshot || {}) as Record<string, unknown>;
const proposed = (row.proposedSnapshot || {}) as Record<string, unknown>;
const base = {
id: row.id.toString(),
storeId: row.storeId.toString(),
storeName: row.store?.name ?? null,
status: row.status,
changedFields: changed,
changedFieldLabels: changed.map((f) => STORE_INFO_CHANGEABLE_FIELD_LABELS[f] ?? f),
submitterType: row.submitterType,
submitterId: row.submitterId.toString(),
rejectReason: row.rejectReason,
reviewedAt: row.reviewedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
if (!withDiffs) return base;
return {
...base,
diffs: changed.map((field) => ({
field,
label: STORE_INFO_CHANGEABLE_FIELD_LABELS[field] ?? field,
live: live[field] ?? null,
proposed: proposed[field] ?? null,
})),
};
}
private async resolvePartnerPrimary(partnerId: string) {
const id = BigInt(partnerId);
const account = await this.prisma.partnerAccount.findUnique({ where: { id } });
if (!account) throw new NotFoundException('合伙人不存在');
if (account.isPrimary === 1) return account;
if (!account.parentAccountId) throw new NotFoundException('合伙人账号无效');
return this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: account.parentAccountId } });
}
}
@@ -1,19 +0,0 @@
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
/** 审计占位:插件无长连接 Bot 行,botKey 固定为 plugin */
export const WECOM_PLUGIN_AUDIT_BOT: WecomBotRuntimeConfig = {
id: '',
key: 'plugin',
role: 'OPERATIONS',
name: '企微 API 插件',
enabled: true,
botId: '',
secret: '',
welcome: '',
avatarUrl: null,
permissions: [],
reviewSuperAdminWecomUserIds: [],
aiEnabled: false,
llmConfigId: null,
knowledgeBaseId: null,
};
@@ -2,7 +2,8 @@ import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { WecomPluginGuard } from './wecom-plugin.guard';
import { WecomPluginQueryService } from './wecom-plugin-query.service';
import { WECOM_PLUGIN_OPENAPI } from './wecom-plugin.openapi';
import { CurrentWecomPlugin } from './wecom-plugin.decorators';
import type { WecomPluginRuntime } from './wecom-plugin.types';
@Controller('wecom/plugin')
@UseGuards(WecomPluginGuard)
@@ -10,73 +11,212 @@ export class WecomPluginController {
constructor(private readonly query: WecomPluginQueryService) {}
@Get()
info() {
return this.query.info();
info(@CurrentWecomPlugin() plugin: WecomPluginRuntime) {
return this.query.info(plugin);
}
@Get('openapi.json')
openapi() {
return WECOM_PLUGIN_OPENAPI;
openapi(@CurrentWecomPlugin() plugin: WecomPluginRuntime) {
return this.query.openapi(plugin);
}
@Get('orders')
orders(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryOrders(pluginCaller(req), q, page, pageSize);
return this.query.queryOrders(plugin, pluginCaller(req), q, page, pageSize);
}
@Get('users')
users(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryUsers(pluginCaller(req), q, page, pageSize);
return this.query.queryUsers(plugin, pluginCaller(req), q, page, pageSize);
}
@Get('stores')
stores(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryStores(pluginCaller(req), q, page, pageSize);
return this.query.queryStores(plugin, pluginCaller(req), q, page, pageSize);
}
@Get('store-audits')
storeAudits(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryStoreAudits(plugin, pluginCaller(req), q, status, page, pageSize);
}
@Get('store-info-audits')
storeInfoAudits(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryStoreInfoAudits(plugin, pluginCaller(req), status, page, pageSize);
}
@Get('store-info-audits/:id')
storeInfoAuditDetail(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Param('id') id: string,
) {
return this.query.queryStoreInfoAuditDetail(plugin, pluginCaller(req), id);
}
@Get('store-package-audits')
storePackageAudits(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryStorePackageAudits(plugin, pluginCaller(req), status, page, pageSize);
}
@Get('store-package-audits/:id')
storePackageAuditDetail(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Param('id') id: string,
) {
return this.query.queryStorePackageAuditDetail(plugin, pluginCaller(req), id);
}
@Get('redeems')
redeems(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryRedeems(pluginCaller(req), q, page, pageSize);
return this.query.queryRedeems(plugin, pluginCaller(req), q, page, pageSize);
}
@Get('promo-codes')
promoCodes(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryPromoCodes(pluginCaller(req), q, page, pageSize);
return this.query.queryPromoCodes(plugin, pluginCaller(req), q, page, pageSize);
}
@Get('promo-codes/:code/stats')
promoStats(@Req() req: Request, @Param('code') code: string) {
return this.query.queryPromoCodeStats(pluginCaller(req), code);
promoStats(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Param('code') code: string,
) {
return this.query.queryPromoCodeStats(plugin, pluginCaller(req), code);
}
@Get('partners')
partners(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryPartners(plugin, pluginCaller(req), q, page, pageSize);
}
@Get('partners/:partnerId/users')
partnerUsers(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Param('partnerId') partnerId: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryPartnerUsers(
plugin,
pluginCaller(req),
partnerId,
from,
to,
page,
pageSize,
);
}
@Get('partners/:partnerId/stores')
partnerStores(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Param('partnerId') partnerId: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryPartnerStores(
plugin,
pluginCaller(req),
partnerId,
from,
to,
page,
pageSize,
);
}
@Get('partners/:partnerId/orders')
partnerOrders(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Param('partnerId') partnerId: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryPartnerOrders(
plugin,
pluginCaller(req),
partnerId,
from,
to,
page,
pageSize,
);
}
@Get('metrics')
metrics(@Req() req: Request, @Query('kind') kind?: string) {
return this.query.queryMetrics(pluginCaller(req), kind);
metrics(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('kind') kind?: string,
) {
return this.query.queryMetrics(plugin, pluginCaller(req), kind);
}
}
@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { WecomPluginRuntime } from './wecom-plugin.types';
export const CurrentWecomPlugin = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): WecomPluginRuntime => {
return ctx.switchToHttp().getRequest().wecomPlugin;
},
);
@@ -4,19 +4,24 @@ import {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { isWecomPluginEnabled, verifyWecomPluginApiKey } from '@dukang/domain';
import { WecomPluginAuthService } from './wecom-plugin-auth.service';
import type { WecomPluginRuntime } from './wecom-plugin.types';
@Injectable()
export class WecomPluginGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
if (!isWecomPluginEnabled(process.env)) {
throw new UnauthorizedException('Unauthorized');
}
const req = context.switchToHttp().getRequest<{ headers: Record<string, unknown> }>();
constructor(private readonly auth: WecomPluginAuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<{
headers: Record<string, unknown>;
wecomPlugin?: WecomPluginRuntime;
}>();
const provided = headerValue(req.headers, 'x-api-key');
if (!verifyWecomPluginApiKey(provided, process.env.WECOM_PLUGIN_API_KEY)) {
const plugin = await this.auth.resolveByApiKey(provided);
if (!plugin) {
throw new UnauthorizedException('Unauthorized');
}
req.wecomPlugin = plugin;
return true;
}
}
@@ -1,3 +1,6 @@
import type { WecomPluginPermission } from '@dukang/shared-types';
import { allowedWecomPluginOpenApiPaths } from '@dukang/shared-types';
const envelope = (dataSchema: Record<string, unknown>) => ({
type: 'object',
properties: {
@@ -32,6 +35,23 @@ const pageParams = [
},
];
const dateRangeParams = [
{
name: 'from',
in: 'query',
required: false,
schema: { type: 'string' },
description: '起始日期 YYYY-MM-DD 或 ISO',
},
{
name: 'to',
in: 'query',
required: false,
schema: { type: 'string' },
description: '结束日期 YYYY-MM-DD 或 ISO(含当天)',
},
];
const unauthorized = {
description: '缺少或错误的 X-Api-Key,或插件未启用',
content: {
@@ -75,6 +95,27 @@ function listPath(summary: string, description: string, qDescription: string) {
};
}
/** 按实例权限过滤 paths,供企微第 2 步对照配置 */
export function filterWecomPluginOpenApi(
spec: typeof WECOM_PLUGIN_OPENAPI,
permissions: WecomPluginPermission[],
title?: string,
) {
const allowedPaths = new Set(allowedWecomPluginOpenApiPaths(permissions));
const paths: Record<string, unknown> = {};
for (const [path, def] of Object.entries(spec.paths)) {
if (allowedPaths.has(path)) paths[path] = def;
}
return {
...spec,
info: {
...spec.info,
title: title || spec.info.title,
},
paths,
};
}
/** OpenAPI 3.0:企微「添加插件工具」可导入。须原样返回,不要套 {code,message,data}。 */
export const WECOM_PLUGIN_OPENAPI = {
openapi: '3.0.3',
@@ -101,7 +142,141 @@ export const WECOM_PLUGIN_OPENAPI = {
paths: {
'/orders': listPath('查询订单', '按订单号模糊查询', '订单号,如 DK20260903xxxx'),
'/users': listPath('查询用户', '按用户号或 11 位手机号查询;手机号脱敏', '用户号或手机号'),
'/stores': listPath('查询门店', '按门店名称模糊查询', '门店名称关键词'),
'/stores': listPath(
'查询门店',
'按门店名称模糊查询,返回经营数据:评分、核销笔数、累计核销好客权益、合伙人、审核状态',
'门店名称关键词',
),
'/store-audits': {
get: {
summary: '门店入驻审核列表',
description: '默认 status=PENDING;可按门店名筛选',
operationId: '查询门店入驻审核',
parameters: [
{ name: 'q', in: 'query', required: false, schema: { type: 'string' }, description: '门店名称' },
{
name: 'status',
in: 'query',
required: false,
schema: { type: 'string', enum: ['PENDING', 'APPROVED', 'REJECTED'], default: 'PENDING' },
},
...pageParams,
],
responses: {
200: { description: '审核列表', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
401: unauthorized,
},
},
},
'/store-info-audits': {
get: {
summary: '门店信息变更审核列表',
operationId: '查询门店信息变更审核',
parameters: [
{
name: 'status',
in: 'query',
required: false,
schema: { type: 'string', enum: ['PENDING', 'APPROVED', 'REJECTED'], default: 'PENDING' },
},
...pageParams,
],
responses: {
200: { description: '列表', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
401: unauthorized,
},
},
},
'/store-info-audits/{id}': {
get: {
summary: '门店信息变更审核对比',
description: '返回变更字段 live vs proposed 对照',
operationId: '查询门店信息变更详情',
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
200: { description: '详情含 diffs', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
401: unauthorized,
},
},
},
'/store-package-audits': {
get: {
summary: '门店套餐审核列表',
operationId: '查询门店套餐审核',
parameters: [
{
name: 'status',
in: 'query',
required: false,
schema: { type: 'string', enum: ['PENDING', 'APPROVED', 'REJECTED'], default: 'PENDING' },
},
...pageParams,
],
responses: {
200: { description: '列表', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
401: unauthorized,
},
},
},
'/store-package-audits/{id}': {
get: {
summary: '门店套餐审核对比',
description: '返回 proposedPackages 与 livePackages 对照',
operationId: '查询门店套餐审核详情',
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
200: { description: '详情含套餐对比', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
401: unauthorized,
},
},
},
'/partners': listPath('查询合伙人', '按姓名、公司名、手机号或 ID 搜索主账号', '合伙人关键词'),
'/partners/{partnerId}/users': {
get: {
summary: '合伙人关联用户',
operationId: '查询合伙人关联用户',
parameters: [
{ name: 'partnerId', in: 'path', required: true, schema: { type: 'string' } },
...dateRangeParams,
...pageParams,
],
responses: {
200: { description: '关联用户列表', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
401: unauthorized,
},
},
},
'/partners/{partnerId}/stores': {
get: {
summary: '合伙人名下门店',
operationId: '查询合伙人门店',
parameters: [
{ name: 'partnerId', in: 'path', required: true, schema: { type: 'string' } },
...dateRangeParams,
...pageParams,
],
responses: {
200: { description: '门店及经营数据', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
401: unauthorized,
},
},
},
'/partners/{partnerId}/orders': {
get: {
summary: '合伙人相关订单',
description: '佣金归属 partnerAccountIdAtPay 的订单',
operationId: '查询合伙人订单',
parameters: [
{ name: 'partnerId', in: 'path', required: true, schema: { type: 'string' } },
...dateRangeParams,
...pageParams,
],
responses: {
200: { description: '订单列表', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
401: unauthorized,
},
},
},
'/redeems': listPath('查询核销', '按核销单号或门店名查询', '核销单号或门店名'),
'/promo-codes': listPath('查询推广码', '按推广码 code 或名称查询', '推广码或名称'),
'/promo-codes/{code}/stats': {
@@ -130,7 +305,7 @@ export const WECOM_PLUGIN_OPENAPI = {
get: {
summary: '经营指标',
description:
'today=今日截至当前;daily/weekly/monthly 与企微经营报告同一口径(用户有效未合并,订单金额=已付 payAmount)。',
'today=今日截至当前;daily/weekly/monthly 与企微经营报告同一口径。stats 含 users/partners/stores/orders 存量与增量;storesIncrement 与 newStores 均为新增门店数。',
operationId: '查询经营指标',
parameters: [
{
@@ -0,0 +1,27 @@
import type { WecomPluginPermission } from '@dukang/shared-types';
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
export type WecomPluginRuntime = {
id: string;
name: string;
permissions: WecomPluginPermission[];
};
export function wecomPluginAuditBot(plugin: WecomPluginRuntime): WecomBotRuntimeConfig {
return {
id: '',
key: `plugin:${plugin.id}`,
role: 'OPERATIONS',
name: plugin.name,
enabled: true,
botId: '',
secret: '',
welcome: '',
avatarUrl: null,
permissions: [],
reviewSuperAdminWecomUserIds: [],
aiEnabled: false,
llmConfigId: null,
knowledgeBaseId: null,
};
}
@@ -11,6 +11,7 @@ import { WecomBotAiService } from './wecom-bot-ai.service';
import { WecomBotAuditService } from './wecom-bot-audit.service';
import { WecomBotCapabilityService } from './wecom-bot-capability.service';
import { WecomBotSessionService } from './wecom-bot-session.service';
import { WecomPluginAuthService } from './wecom-plugin-auth.service';
import { WecomPluginController } from './wecom-plugin.controller';
import { WecomPluginGuard } from './wecom-plugin.guard';
import { WecomPluginQueryService } from './wecom-plugin-query.service';
@@ -33,6 +34,7 @@ import { WecomPluginQueryService } from './wecom-plugin-query.service';
WecomBotActionsService,
WecomBotAiService,
WecomAibotService,
WecomPluginAuthService,
WecomPluginGuard,
WecomPluginQueryService,
],