v4.0.17企业微信API插件优化
This commit is contained in:
@@ -67,10 +67,6 @@ WX_MINI_MSG_AES_KEY=
|
||||
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
|
||||
WECOM_AIBOT_ENABLED=false
|
||||
|
||||
# 企微「API 插件」只读数据面(与长连接 Bot 独立;密钥勿提交)
|
||||
WECOM_PLUGIN_ENABLED=false
|
||||
# WECOM_PLUGIN_API_KEY=
|
||||
|
||||
# 运营告警 Webhook(已废弃运行时读取,仅 seed 一次性导入到 HQ「消息推送」)
|
||||
# 配置后执行 pnpm prisma:seed-wecom-push 或 API 启动时自动 ensureDefaults
|
||||
# WECOM_ALERT_ENABLED=false
|
||||
|
||||
@@ -54,10 +54,6 @@ WX_MINI_MSG_AES_KEY=
|
||||
# 企业微信机器人总开关(实例在 HQ 企微机器人模块维护)
|
||||
WECOM_AIBOT_ENABLED=false
|
||||
|
||||
# 企微 API 插件只读数据面(与长连接 Bot 独立)
|
||||
WECOM_PLUGIN_ENABLED=false
|
||||
# WECOM_PLUGIN_API_KEY=
|
||||
|
||||
# 运营告警:企业微信群机器人 Webhook
|
||||
# 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」)
|
||||
# WECOM_ALERT_ENABLED=false
|
||||
|
||||
@@ -54,10 +54,6 @@ WX_MINI_MSG_AES_KEY=
|
||||
|
||||
WECOM_AIBOT_ENABLED=false
|
||||
|
||||
# 企微 API 插件(测试环境单独一把 Key)
|
||||
WECOM_PLUGIN_ENABLED=false
|
||||
# WECOM_PLUGIN_API_KEY=
|
||||
|
||||
# 运营告警:企业微信群机器人 Webhook
|
||||
# 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」)
|
||||
# WECOM_ALERT_ENABLED=false
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"prisma:seed-wecom-push": "ts-node --transpile-only prisma/seed-wecom-message-push.ts",
|
||||
"prisma:migrate-wecom-push": "ts-node --transpile-only prisma/migrate-wecom-message-push.ts",
|
||||
"prisma:migrate-wecom-report": "ts-node --transpile-only prisma/migrate-wecom-report.ts",
|
||||
"prisma:migrate-wecom-api-plugin": "ts-node --transpile-only prisma/migrate-wecom-api-plugin.ts",
|
||||
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
|
||||
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts",
|
||||
"prisma:merge-spu-dk000007-008": "ts-node --transpile-only prisma/merge-spu-dk000007-008.ts",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 创建 wecom_api_plugin(若不存在)。
|
||||
* 不依赖 Prisma Client 新 model,可在 generate 之前执行。
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS wecom_api_plugin (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(64) NOT NULL,
|
||||
api_key VARCHAR(128) NOT NULL,
|
||||
permissions TEXT NOT NULL,
|
||||
remark VARCHAR(256) NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
UNIQUE KEY wecom_api_plugin_api_key_key (api_key),
|
||||
KEY wecom_api_plugin_enabled_sort (enabled, sort_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
console.log('migrate-wecom-api-plugin done');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -493,6 +493,23 @@ model WecomBot {
|
||||
@@map("wecom_bot")
|
||||
}
|
||||
|
||||
/// 企微智能机器人 API 插件实例(v3.5.17 · 多 Key + 工具权限)
|
||||
model WecomApiPlugin {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
name String @db.VarChar(64)
|
||||
apiKey String @unique @map("api_key") @db.VarChar(128)
|
||||
/// JSON 字符串数组,工具权限
|
||||
permissions String @db.Text
|
||||
remark String? @db.VarChar(256)
|
||||
enabled Boolean @default(true)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
@@index([enabled, sortOrder])
|
||||
@@map("wecom_api_plugin")
|
||||
}
|
||||
|
||||
/// 企微机器人操作审计
|
||||
model LogWecomBot {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
|
||||
@@ -51,7 +51,6 @@ const fixed = {
|
||||
WX_PAY_NOTIFY_URL: 'https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay',
|
||||
OSS_UPLOAD_PREFIX: 'staging/uploads',
|
||||
WECOM_AIBOT_ENABLED: 'false',
|
||||
WECOM_PLUGIN_ENABLED: 'false',
|
||||
};
|
||||
|
||||
const preferFromProd = [
|
||||
|
||||
@@ -95,6 +95,10 @@ export const HqOperationAction = {
|
||||
WECOM_MESSAGE_PUSH_UPDATE: 'WECOM_MESSAGE_PUSH_UPDATE',
|
||||
WECOM_MESSAGE_PUSH_DELETE: 'WECOM_MESSAGE_PUSH_DELETE',
|
||||
WECOM_MESSAGE_PUSH_TEST: 'WECOM_MESSAGE_PUSH_TEST',
|
||||
WECOM_API_PLUGIN_CREATE: 'WECOM_API_PLUGIN_CREATE',
|
||||
WECOM_API_PLUGIN_UPDATE: 'WECOM_API_PLUGIN_UPDATE',
|
||||
WECOM_API_PLUGIN_DELETE: 'WECOM_API_PLUGIN_DELETE',
|
||||
WECOM_API_PLUGIN_ROTATE_KEY: 'WECOM_API_PLUGIN_ROTATE_KEY',
|
||||
WECOM_REPORT_UPDATE: 'WECOM_REPORT_UPDATE',
|
||||
WECOM_REPORT_SEND: 'WECOM_REPORT_SEND',
|
||||
LLM_CONFIG_CREATE: 'LLM_CONFIG_CREATE',
|
||||
@@ -232,6 +236,10 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.WECOM_MESSAGE_PUSH_UPDATE]: '编辑企微消息推送',
|
||||
[HqOperationAction.WECOM_MESSAGE_PUSH_DELETE]: '删除企微消息推送',
|
||||
[HqOperationAction.WECOM_MESSAGE_PUSH_TEST]: '测试企微消息推送',
|
||||
[HqOperationAction.WECOM_API_PLUGIN_CREATE]: '创建企微 API 插件',
|
||||
[HqOperationAction.WECOM_API_PLUGIN_UPDATE]: '编辑企微 API 插件',
|
||||
[HqOperationAction.WECOM_API_PLUGIN_DELETE]: '删除企微 API 插件',
|
||||
[HqOperationAction.WECOM_API_PLUGIN_ROTATE_KEY]: '轮换企微 API 插件密钥',
|
||||
[HqOperationAction.WECOM_REPORT_UPDATE]: '编辑企微经营报告',
|
||||
[HqOperationAction.WECOM_REPORT_SEND]: '发送企微经营报告',
|
||||
[HqOperationAction.LLM_CONFIG_CREATE]: '创建语言模型配置',
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateWecomApiPluginRequest,
|
||||
UpdateWecomApiPluginRequest,
|
||||
} from '@dukang/shared-types';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminWecomApiPluginsService } from './admin-wecom-api-plugins.service';
|
||||
|
||||
@Controller('admin/wecom-api-plugins')
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('wecom_bots')
|
||||
export class AdminWecomApiPluginsController {
|
||||
constructor(private readonly service: AdminWecomApiPluginsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query('name') name?: string,
|
||||
@Query('enabled') enabled?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.list({
|
||||
name,
|
||||
enabled,
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WECOM_API_PLUGIN_CREATE,
|
||||
refType: 'WECOM_API_PLUGIN',
|
||||
includeBody: false,
|
||||
})
|
||||
create(@Body() body: CreateWecomApiPluginRequest) {
|
||||
return this.service.create(body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WECOM_API_PLUGIN_UPDATE,
|
||||
refType: 'WECOM_API_PLUGIN',
|
||||
refIdField: 'id',
|
||||
includeBody: false,
|
||||
})
|
||||
update(@Param('id') id: string, @Body() body: UpdateWecomApiPluginRequest) {
|
||||
return this.service.update(BigInt(id), body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WECOM_API_PLUGIN_DELETE,
|
||||
refType: 'WECOM_API_PLUGIN',
|
||||
refIdField: 'id',
|
||||
})
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/rotate-key')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WECOM_API_PLUGIN_ROTATE_KEY,
|
||||
refType: 'WECOM_API_PLUGIN',
|
||||
refIdField: 'id',
|
||||
})
|
||||
rotateKey(@Param('id') id: string) {
|
||||
return this.service.rotateKey(BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
maskWecomPluginApiKey,
|
||||
parseWecomPluginPermissions,
|
||||
type CreateWecomApiPluginRequest,
|
||||
type UpdateWecomApiPluginRequest,
|
||||
type WecomApiPluginDto,
|
||||
type WecomApiPluginSecretDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
const MIN_KEY_LEN = 16;
|
||||
|
||||
@Injectable()
|
||||
export class AdminWecomApiPluginsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: { name?: string; enabled?: string; page?: number; pageSize?: number }) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: { name?: { contains: string }; enabled?: boolean } = {};
|
||||
if (query.name?.trim()) where.name = { contains: query.name.trim() };
|
||||
if (query.enabled === 'true' || query.enabled === 'false') {
|
||||
where.enabled = query.enabled === 'true';
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.wecomApiPlugin.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.wecomApiPlugin.count({ where }),
|
||||
]);
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((row) => this.toDto(row)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async detail(id: bigint): Promise<WecomApiPluginDto> {
|
||||
return this.toDto(await this.mustGet(id));
|
||||
}
|
||||
|
||||
async create(dto: CreateWecomApiPluginRequest): Promise<WecomApiPluginSecretDto> {
|
||||
const name = dto.name?.trim();
|
||||
if (!name) throw new BadRequestException('请填写名称');
|
||||
const permissions = parseWecomPluginPermissions(dto.permissions);
|
||||
if (!permissions.length) throw new BadRequestException('请至少勾选一项工具权限');
|
||||
const apiKey = this.normalizeApiKey(dto.apiKey, true);
|
||||
|
||||
try {
|
||||
const row = await this.prisma.wecomApiPlugin.create({
|
||||
data: {
|
||||
name,
|
||||
apiKey,
|
||||
permissions: JSON.stringify(permissions),
|
||||
remark: dto.remark?.trim() || null,
|
||||
enabled: dto.enabled !== false,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
return { ...this.toDto(row), apiKey };
|
||||
} catch (e) {
|
||||
this.rethrowUniqueKey(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateWecomApiPluginRequest): Promise<WecomApiPluginDto> {
|
||||
const existing = await this.mustGet(id);
|
||||
const data: Prisma.WecomApiPluginUpdateInput = {};
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('请填写名称');
|
||||
data.name = name;
|
||||
}
|
||||
if (dto.permissions !== undefined) {
|
||||
const permissions = parseWecomPluginPermissions(dto.permissions);
|
||||
if (!permissions.length) throw new BadRequestException('请至少勾选一项工具权限');
|
||||
data.permissions = JSON.stringify(permissions);
|
||||
}
|
||||
if (dto.remark !== undefined) data.remark = dto.remark?.trim() || null;
|
||||
if (dto.enabled !== undefined) data.enabled = dto.enabled;
|
||||
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
||||
if (dto.apiKey !== undefined && String(dto.apiKey).trim()) {
|
||||
data.apiKey = this.normalizeApiKey(dto.apiKey, false);
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await this.prisma.wecomApiPlugin.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
this.rethrowUniqueKey(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.mustGet(id);
|
||||
await this.prisma.wecomApiPlugin.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async rotateKey(id: bigint): Promise<WecomApiPluginSecretDto> {
|
||||
await this.mustGet(id);
|
||||
const apiKey = generateApiKey();
|
||||
try {
|
||||
const row = await this.prisma.wecomApiPlugin.update({
|
||||
where: { id },
|
||||
data: { apiKey },
|
||||
});
|
||||
return { ...this.toDto(row), apiKey };
|
||||
} catch (e) {
|
||||
this.rethrowUniqueKey(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async mustGet(id: bigint) {
|
||||
const row = await this.prisma.wecomApiPlugin.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('API 插件不存在');
|
||||
return row;
|
||||
}
|
||||
|
||||
private normalizeApiKey(raw: string | undefined, generateIfEmpty: boolean): string {
|
||||
const v = String(raw ?? '').trim();
|
||||
if (!v) {
|
||||
if (generateIfEmpty) return generateApiKey();
|
||||
throw new BadRequestException('请填写 API Key');
|
||||
}
|
||||
if (v.length < MIN_KEY_LEN) {
|
||||
throw new BadRequestException(`API Key 至少 ${MIN_KEY_LEN} 位`);
|
||||
}
|
||||
if (v.length > 128) throw new BadRequestException('API Key 过长');
|
||||
return v;
|
||||
}
|
||||
|
||||
private rethrowUniqueKey(e: unknown): void {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new BadRequestException('API Key 已存在,请更换');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
permissions: string;
|
||||
remark: string | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): WecomApiPluginDto {
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
apiKeyConfigured: Boolean(row.apiKey),
|
||||
apiKeyMasked: maskWecomPluginApiKey(row.apiKey),
|
||||
permissions: parseWecomPluginPermissions(row.permissions),
|
||||
remark: row.remark,
|
||||
enabled: row.enabled,
|
||||
sortOrder: row.sortOrder,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function generateApiKey(): string {
|
||||
return `dkp_${randomBytes(24).toString('hex')}`;
|
||||
}
|
||||
@@ -69,6 +69,8 @@ import { AdminWecomBotsController } from './admin-wecom-bots.controller';
|
||||
import { AdminWecomBotsService } from './admin-wecom-bots.service';
|
||||
import { AdminWecomMessagePushesController } from './admin-wecom-message-pushes.controller';
|
||||
import { AdminWecomMessagePushesService } from './admin-wecom-message-pushes.service';
|
||||
import { AdminWecomApiPluginsController } from './admin-wecom-api-plugins.controller';
|
||||
import { AdminWecomApiPluginsService } from './admin-wecom-api-plugins.service';
|
||||
import { AdminWecomReportsController } from './admin-wecom-reports.controller';
|
||||
import { AdminWecomReportsService } from './admin-wecom-reports.service';
|
||||
import { AdminWecomPushTemplatesController } from './admin-wecom-push-templates.controller';
|
||||
@@ -130,6 +132,7 @@ import { PartnerActivityPostersController } from './partner-activity-posters.con
|
||||
AdminSystemConfigController,
|
||||
AdminWecomBotsController,
|
||||
AdminWecomMessagePushesController,
|
||||
AdminWecomApiPluginsController,
|
||||
AdminWecomReportsController,
|
||||
AdminWecomPushTemplatesController,
|
||||
AdminWecomBotLogsController,
|
||||
@@ -169,6 +172,7 @@ import { PartnerActivityPostersController } from './partner-activity-posters.con
|
||||
AdminDeployService,
|
||||
AdminWecomBotsService,
|
||||
AdminWecomMessagePushesService,
|
||||
AdminWecomApiPluginsService,
|
||||
AdminWecomReportsService,
|
||||
AdminWecomBotLogsService,
|
||||
AdminLlmConfigsService,
|
||||
|
||||
Reference in New Issue
Block a user