+
+
+
+ 企微机器人
+
+
+ 并列创建多个智能机器人,配置 BotID / Secret / 权限 / 角色 / 头像。总开关在「系统设置 → 功能开关」。
+ {runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ setPage(p);
+ setPageSize(ps);
+ },
+ }}
+ />
+
+ setModalOpen(false)}
+ onOk={() => void submit()}
+ confirmLoading={saving}
+ width={640}
+ destroyOnClose
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ WECOM_BOT_PERMISSION_LABELS[p]).join('、') || '无'}`
+ : undefined
+ }
+ >
+ ({
+ value: p,
+ label: WECOM_BOT_PERMISSION_LABELS[p],
+ }))}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setDetail(null)}>
+ {detail && (
+
+
+
+ {detail.name.slice(0, 1)}
+
+
+ {detail.name}
+
+ {WECOM_BOT_ROLE_LABELS[detail.role] || detail.role}
+
+ {detail.botId}
+
+ {detail.secretConfigured ? '已配置' : '未配置'}
+
+
+ {detail.permissions.map((p) => WECOM_BOT_PERMISSION_LABELS[p] || p).join('、')}
+
+ {detail.welcome || '—'}
+ {detail.enabled ? '是' : '否'}
+ {detail.sortOrder}
+ {fmtTime(detail.createdAt)}
+ {fmtTime(detail.updatedAt)}
+
+ )}
+
+
+ );
+}
diff --git a/packages/shared-types/src/hq-permissions.ts b/packages/shared-types/src/hq-permissions.ts
index 8ac023b..ce62312 100644
--- a/packages/shared-types/src/hq-permissions.ts
+++ b/packages/shared-types/src/hq-permissions.ts
@@ -14,6 +14,7 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'tickets', label: '工单中心', group: '业务' },
{ key: 'tech_support', label: '技术支持', group: '业务' },
{ key: 'invoices', label: '发票管理', group: '业务' },
+ { key: 'wecom_bots', label: '企微机器人', group: '业务' },
{ key: 'resources', label: 'OSS 资源库', group: '业务' },
{ key: 'logs', label: '日志', group: '业务' },
{ key: 'hq_permissions', label: '权限分配', group: '管理' },
@@ -88,6 +89,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record = {
'tickets',
'tech_support',
'invoices',
+ 'wecom_bots',
'resources',
'logs',
'system_settings_wechat_mini',
diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts
index 4cb37c1..674cdea 100644
--- a/packages/shared-types/src/index.ts
+++ b/packages/shared-types/src/index.ts
@@ -23,4 +23,5 @@ export * from './city-partner';
export * from './city-warehouse';
export * from './fulfillment-provider';
export * from './system-config';
+export * from './wecom-bot';
export * from './legal';
diff --git a/packages/shared-types/src/wecom-bot.ts b/packages/shared-types/src/wecom-bot.ts
new file mode 100644
index 0000000..ef9e96e
--- /dev/null
+++ b/packages/shared-types/src/wecom-bot.ts
@@ -0,0 +1,107 @@
+/** 企业微信智能机器人能力权限 */
+export const WECOM_BOT_PERMISSIONS = [
+ 'ticket.create',
+ 'user.view_sms',
+ 'delivery.view',
+ 'support_ticket.create',
+ 'support_ticket.progress',
+ 'handbook.query',
+] as const;
+
+export type WecomBotPermission = (typeof WECOM_BOT_PERMISSIONS)[number];
+
+export const WECOM_BOT_PERMISSION_LABELS: Record = {
+ 'ticket.create': '创建售后工单',
+ 'user.view_sms': '查用户(短信验证)',
+ 'delivery.view': '查快递信息',
+ 'support_ticket.create': '创建技术支持工单',
+ 'support_ticket.progress': '查看开发进度',
+ 'handbook.query': '查询使用手册',
+};
+
+/** 预置机器人角色(创建时可选;权限可按角色带出默认值后自定义) */
+export const WECOM_BOT_ROLES = [
+ 'CUSTOMER_SERVICE',
+ 'TECH_SUPPORT',
+ 'TEAM_ASSISTANT',
+ 'CUSTOM',
+] as const;
+
+export type WecomBotRole = (typeof WECOM_BOT_ROLES)[number];
+
+export const WECOM_BOT_ROLE_LABELS: Record = {
+ CUSTOMER_SERVICE: '客服机器人',
+ TECH_SUPPORT: '技术支持机器人',
+ TEAM_ASSISTANT: '团队助手',
+ CUSTOM: '自定义',
+};
+
+export const WECOM_BOT_ROLE_DEFAULT_PERMISSIONS: Record = {
+ CUSTOMER_SERVICE: ['ticket.create', 'user.view_sms', 'delivery.view'],
+ TECH_SUPPORT: ['support_ticket.create', 'support_ticket.progress'],
+ TEAM_ASSISTANT: ['handbook.query'],
+ CUSTOM: [],
+};
+
+export function parseWecomBotPermissions(
+ raw?: string | string[] | null,
+): WecomBotPermission[] {
+ const set = new Set(WECOM_BOT_PERMISSIONS);
+ const list = Array.isArray(raw)
+ ? raw
+ : String(raw ?? '')
+ .split(/[,,\s]+/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+ return [...new Set(list.filter((s): s is WecomBotPermission => set.has(s)))];
+}
+
+export function resolveWecomBotPermissions(
+ role: WecomBotRole,
+ override?: string | string[] | null,
+): WecomBotPermission[] {
+ const parsed = parseWecomBotPermissions(override);
+ if (parsed.length) return parsed;
+ return [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[role]];
+}
+
+export type WecomBotDto = {
+ id: string;
+ name: string;
+ role: WecomBotRole;
+ botId: string;
+ /** 列表/详情不返回明文;仅表示是否已配置 */
+ secretConfigured: boolean;
+ avatarUrl: string | null;
+ welcome: string | null;
+ permissions: WecomBotPermission[];
+ enabled: boolean;
+ sortOrder: number;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type CreateWecomBotRequest = {
+ name: string;
+ role: WecomBotRole;
+ botId: string;
+ secret: string;
+ avatarUrl?: string | null;
+ welcome?: string | null;
+ permissions?: WecomBotPermission[];
+ enabled?: boolean;
+ sortOrder?: number;
+};
+
+export type UpdateWecomBotRequest = {
+ name?: string;
+ role?: WecomBotRole;
+ botId?: string;
+ /** 空或不传表示不修改 */
+ secret?: string;
+ avatarUrl?: string | null;
+ welcome?: string | null;
+ permissions?: WecomBotPermission[];
+ enabled?: boolean;
+ sortOrder?: number;
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3f6db90..fc4ce32 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -429,6 +429,9 @@ importers:
'@prisma/client':
specifier: ^5.18.0
version: 5.22.0(prisma@5.22.0)
+ '@wecom/aibot-node-sdk':
+ specifier: ^1.0.7
+ version: 1.0.7
ali-oss:
specifier: ^6.23.0
version: 6.23.0
@@ -2775,6 +2778,9 @@ packages:
'@webassemblyjs/wast-printer@1.14.1':
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
+ '@wecom/aibot-node-sdk@1.0.7':
+ resolution: {integrity: sha512-51w+sTqunry6GD3HFvmuh0gArMSJDFE418vyvR1wMJHj1N6DaFuGD3HuaY2fazZs3mb9FWeQFX3+vU5t0Qhwmw==}
+
'@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@@ -3737,6 +3743,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
+ eventemitter3@5.0.4:
+ resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@@ -6511,6 +6520,18 @@ packages:
resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+ ws@8.21.1:
+ resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
xml2js@0.6.2:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
@@ -9358,6 +9379,17 @@ snapshots:
'@webassemblyjs/ast': 1.14.1
'@xtuc/long': 4.2.2
+ '@wecom/aibot-node-sdk@1.0.7':
+ dependencies:
+ axios: 1.18.1
+ eventemitter3: 5.0.4
+ ws: 8.21.1
+ transitivePeerDependencies:
+ - bufferutil
+ - debug
+ - supports-color
+ - utf-8-validate
+
'@xtuc/ieee754@1.2.0': {}
'@xtuc/long@4.2.2': {}
@@ -10525,6 +10557,8 @@ snapshots:
etag@1.8.1: {}
+ eventemitter3@5.0.4: {}
+
events@3.3.0: {}
execa@8.0.1:
@@ -13562,6 +13596,8 @@ snapshots:
imurmurhash: 0.1.4
signal-exit: 4.1.0
+ ws@8.21.1: {}
+
xml2js@0.6.2:
dependencies:
sax: 1.6.0
diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example
index ef00593..d9b620c 100644
--- a/server/dukang-api/.env.example
+++ b/server/dukang-api/.env.example
@@ -56,6 +56,9 @@ WX_API_V3_KEY=
WX_PLATFORM_CERT=
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
+# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
+WECOM_AIBOT_ENABLED=false
+
# 腾讯位置服务(逆地理编码,微信定位展示城市)
TENCENT_LBS_KEY=
diff --git a/server/dukang-api/.env.production.example b/server/dukang-api/.env.production.example
index b212bef..90fd272 100644
--- a/server/dukang-api/.env.production.example
+++ b/server/dukang-api/.env.production.example
@@ -43,6 +43,9 @@ WX_API_V3_KEY=
WX_PLATFORM_CERT=
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
+# 企业微信机器人总开关(实例在 HQ 企微机器人模块维护)
+WECOM_AIBOT_ENABLED=false
+
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_BUCKET=dukang-dev
diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json
index fc9f566..f3a5238 100644
--- a/server/dukang-api/package.json
+++ b/server/dukang-api/package.json
@@ -30,6 +30,7 @@
"@nestjs/platform-express": "^10.4.0",
"@nestjs/schedule": "^6.1.3",
"@prisma/client": "^5.18.0",
+ "@wecom/aibot-node-sdk": "^1.0.7",
"ali-oss": "^6.23.0",
"bullmq": "^5.12.0",
"class-transformer": "^0.5.1",
diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma
index 0af7265..8ecfba5 100644
--- a/server/dukang-api/prisma/schema.prisma
+++ b/server/dukang-api/prisma/schema.prisma
@@ -350,6 +350,26 @@ model SystemConfig {
@@map("system_config")
}
+/// 企业微信智能机器人(HQ 可创建多实例,长连接)
+model WecomBot {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ name String @db.VarChar(64)
+ role String @db.VarChar(32)
+ botId String @unique @map("bot_id") @db.VarChar(128)
+ secret String @db.VarChar(256)
+ avatarUrl String? @map("avatar_url") @db.VarChar(512)
+ welcome String? @db.VarChar(1024)
+ /// JSON 字符串数组,如 ["ticket.create","user.view_sms"]
+ permissions String @db.Text
+ 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_bot")
+}
+
model MockSmsCode {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
phone String @db.VarChar(20)
diff --git a/server/dukang-api/prisma/wecom_bot.sql b/server/dukang-api/prisma/wecom_bot.sql
new file mode 100644
index 0000000..63fc864
--- /dev/null
+++ b/server/dukang-api/prisma/wecom_bot.sql
@@ -0,0 +1,17 @@
+CREATE TABLE IF NOT EXISTS `wecom_bot` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `name` VARCHAR(64) NOT NULL,
+ `role` VARCHAR(32) NOT NULL,
+ `bot_id` VARCHAR(128) NOT NULL,
+ `secret` VARCHAR(256) NOT NULL,
+ `avatar_url` VARCHAR(512) NULL,
+ `welcome` VARCHAR(1024) NULL,
+ `permissions` TEXT NOT NULL,
+ `enabled` BOOLEAN NOT NULL DEFAULT true,
+ `sort_order` INT NOT NULL DEFAULT 0,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+ UNIQUE INDEX `wecom_bot_bot_id_key`(`bot_id`),
+ INDEX `wecom_bot_enabled_sort_order_idx`(`enabled`, `sort_order`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
diff --git a/server/dukang-api/src/app.module.ts b/server/dukang-api/src/app.module.ts
index 12486ab..0dfa18f 100644
--- a/server/dukang-api/src/app.module.ts
+++ b/server/dukang-api/src/app.module.ts
@@ -21,6 +21,7 @@ import { CommonModule } from './modules/common/common.module';
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
import { SystemConfigModule } from './common/system-config/system-config.module';
import { CallbacksModule } from './callbacks/callbacks.module';
+import { WecomModule } from './integrations/wecom/wecom.module';
@Module({
imports: [
@@ -50,6 +51,7 @@ import { CallbacksModule } from './callbacks/callbacks.module';
CommonModule,
HqOperationModule,
CallbacksModule,
+ WecomModule,
],
})
export class AppModule {}
diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
index 99f4672..a8f6140 100644
--- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
+++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
@@ -72,6 +72,10 @@ export const HqOperationAction = {
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
PROMO_CODE_UPDATE: 'PROMO_CODE_UPDATE',
PROMO_CODE_UPDATE_STATUS: 'PROMO_CODE_UPDATE_STATUS',
+ WECOM_BOT_CREATE: 'WECOM_BOT_CREATE',
+ WECOM_BOT_UPDATE: 'WECOM_BOT_UPDATE',
+ WECOM_BOT_DELETE: 'WECOM_BOT_DELETE',
+ WECOM_BOT_RELOAD: 'WECOM_BOT_RELOAD',
REDEEM_PENDING_COMPLETE: 'REDEEM_PENDING_COMPLETE',
REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT',
DEPLOY_TRIGGER: 'DEPLOY_TRIGGER',
@@ -155,6 +159,10 @@ export const HQ_OPERATION_ACTION_LABELS: Record = {
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
[HqOperationAction.PROMO_CODE_UPDATE]: '编辑推广码',
[HqOperationAction.PROMO_CODE_UPDATE_STATUS]: '推广码启停',
+ [HqOperationAction.WECOM_BOT_CREATE]: '创建企微机器人',
+ [HqOperationAction.WECOM_BOT_UPDATE]: '编辑企微机器人',
+ [HqOperationAction.WECOM_BOT_DELETE]: '删除企微机器人',
+ [HqOperationAction.WECOM_BOT_RELOAD]: '重载企微机器人连接',
[HqOperationAction.REDEEM_PENDING_COMPLETE]: '弱网待处理单-补核销',
[HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回',
[HqOperationAction.DEPLOY_TRIGGER]: '触发系统发布',
diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts
index 6338c37..61f7135 100644
--- a/server/dukang-api/src/common/system-config/system-config.registry.ts
+++ b/server/dukang-api/src/common/system-config/system-config.registry.ts
@@ -51,6 +51,14 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
},
{ key: 'MOCK_DELIVERY_AUTO', label: 'Mock 配送自动完成', group: G.feature, type: 'boolean', requiresRestart: false },
{ key: 'AUTO_APPROVE_STORE', label: '门店自动审核通过', group: G.feature, type: 'boolean', requiresRestart: false },
+ {
+ key: 'WECOM_AIBOT_ENABLED',
+ label: '启用企微机器人长连接',
+ group: G.feature,
+ type: 'boolean',
+ requiresRestart: false,
+ description: '总开关。开启后连接 HQ「企微机器人」模块中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)',
+ },
{ key: 'ALIYUN_SMS_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false },
@@ -150,6 +158,24 @@ export const SYSTEM_CONFIG_RETIRED_KEYS = [
'WECHAT_AUTH_ENABLED',
'WECHAT_PAY_ENABLED',
'OSS_ENABLED',
+ 'WECOM_AIBOT_BOT_ID',
+ 'WECOM_AIBOT_SECRET',
+ 'WECOM_AIBOT_WELCOME',
+ 'WECOM_BOT_CS_ENABLED',
+ 'WECOM_BOT_CS_BOT_ID',
+ 'WECOM_BOT_CS_SECRET',
+ 'WECOM_BOT_CS_WELCOME',
+ 'WECOM_BOT_CS_PERMISSIONS',
+ 'WECOM_BOT_TECH_ENABLED',
+ 'WECOM_BOT_TECH_BOT_ID',
+ 'WECOM_BOT_TECH_SECRET',
+ 'WECOM_BOT_TECH_WELCOME',
+ 'WECOM_BOT_TECH_PERMISSIONS',
+ 'WECOM_BOT_TEAM_ENABLED',
+ 'WECOM_BOT_TEAM_BOT_ID',
+ 'WECOM_BOT_TEAM_SECRET',
+ 'WECOM_BOT_TEAM_WELCOME',
+ 'WECOM_BOT_TEAM_PERMISSIONS',
] as const;
export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.key));
diff --git a/server/dukang-api/src/integrations/integrations.module.ts b/server/dukang-api/src/integrations/integrations.module.ts
index 0484a81..8e239df 100644
--- a/server/dukang-api/src/integrations/integrations.module.ts
+++ b/server/dukang-api/src/integrations/integrations.module.ts
@@ -49,6 +49,16 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
TencentLbsProvider,
{ provide: MAP_PROVIDER, useExisting: TencentLbsProvider },
],
- exports: [SMS_PROVIDER, SmsCodeStore, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, MAP_PROVIDER, TencentLbsProvider, CourierModule],
+ exports: [
+ SMS_PROVIDER,
+ SmsCodeStore,
+ PAY_PROVIDER,
+ DELIVERY_PROVIDER,
+ WECHAT_PROVIDER,
+ OSS_PROVIDER,
+ MAP_PROVIDER,
+ TencentLbsProvider,
+ CourierModule,
+ ],
})
export class IntegrationsModule {}
diff --git a/server/dukang-api/src/integrations/wecom/wecom-aibot.service.ts b/server/dukang-api/src/integrations/wecom/wecom-aibot.service.ts
new file mode 100644
index 0000000..339ebf4
--- /dev/null
+++ b/server/dukang-api/src/integrations/wecom/wecom-aibot.service.ts
@@ -0,0 +1,313 @@
+import {
+ Injectable,
+ Logger,
+ OnModuleDestroy,
+ OnModuleInit,
+} from '@nestjs/common';
+import { WSClient, generateReqId, type WsFrame } from '@wecom/aibot-node-sdk';
+import {
+ WECOM_BOT_ROLES,
+ resolveWecomBotPermissions,
+ type WecomBotRole,
+} from '@dukang/shared-types';
+import { PrismaService } from '../../common/prisma/prisma.module';
+import { WecomBotActionsService } from './wecom-bot-actions.service';
+import type { WecomBotRuntimeConfig } from './wecom-bot.types';
+
+const DEFAULT_WELCOMES: Record = {
+ CUSTOMER_SERVICE:
+ '您好,我是杜康好客【客服】助手。发送「帮助」查看:创建售后工单、查用户(需短信验证)、查快递。',
+ TECH_SUPPORT:
+ '您好,我是杜康好客【技术支持】助手。发送「帮助」查看:创建技术支持工单、查看开发进度。',
+ TEAM_ASSISTANT:
+ '您好,我是杜康好客【团队助手】。发送「帮助」或「手册 关键词」查询系统使用说明。',
+ CUSTOM: '您好!发送「帮助」查看可用指令。',
+};
+
+export type WecomAibotSlotStatus = {
+ id: string;
+ role: string;
+ key: string;
+ name: string;
+ enabled: boolean;
+ configured: boolean;
+ connected: boolean;
+ botIdMasked: string | null;
+ avatarUrl: string | null;
+ permissions: string[];
+ lastError: string | null;
+};
+
+export type WecomAibotStatus = {
+ masterEnabled: boolean;
+ bots: WecomAibotSlotStatus[];
+};
+
+type BotRuntime = {
+ config: WecomBotRuntimeConfig;
+ client: WSClient | null;
+ lastError: string | null;
+};
+
+function isWecomRole(v: string): v is WecomBotRole {
+ return (WECOM_BOT_ROLES as readonly string[]).includes(v);
+}
+
+@Injectable()
+export class WecomAibotService implements OnModuleInit, OnModuleDestroy {
+ private readonly logger = new Logger(WecomAibotService.name);
+ private runtimes = new Map();
+ private starting = false;
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly actions: WecomBotActionsService,
+ ) {}
+
+ async onModuleInit() {
+ await this.reload('boot');
+ }
+
+ async onModuleDestroy() {
+ this.stopAll('shutdown');
+ }
+
+ getStatus(): WecomAibotStatus {
+ const masterEnabled = process.env.WECOM_AIBOT_ENABLED === 'true';
+ const bots: WecomAibotSlotStatus[] = [];
+ for (const rt of this.runtimes.values()) {
+ const cfg = rt.config;
+ bots.push({
+ id: cfg.id,
+ role: cfg.role,
+ key: cfg.key,
+ name: cfg.name,
+ enabled: cfg.enabled,
+ configured: !!(cfg.botId && cfg.secret),
+ connected: !!rt.client?.isConnected,
+ botIdMasked: cfg.botId ? maskId(cfg.botId) : null,
+ avatarUrl: cfg.avatarUrl,
+ permissions: cfg.permissions,
+ lastError: rt.lastError,
+ });
+ }
+ return { masterEnabled, bots };
+ }
+
+ async reload(reason = 'config'): Promise {
+ if (this.starting) {
+ this.logger.warn(`wecom aibot reload skipped (busy): ${reason}`);
+ return this.getStatus();
+ }
+ this.starting = true;
+ try {
+ this.stopAll(reason);
+ const masterEnabled = process.env.WECOM_AIBOT_ENABLED === 'true';
+ const rows = await this.prisma.wecomBot.findMany({
+ orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
+ });
+
+ if (!masterEnabled) {
+ this.logger.log(`wecom aibot master disabled (${reason})`);
+ for (const row of rows) {
+ const cfg = this.rowToConfig(row);
+ this.runtimes.set(cfg.key, { config: cfg, client: null, lastError: null });
+ }
+ return this.getStatus();
+ }
+
+ for (const row of rows) {
+ const cfg = this.rowToConfig(row);
+ if (!cfg.enabled || !cfg.botId || !cfg.secret) {
+ this.runtimes.set(cfg.key, { config: cfg, client: null, lastError: null });
+ continue;
+ }
+ try {
+ await this.startBot(cfg);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ this.logger.error(`wecom bot ${cfg.key} start failed: ${msg}`);
+ this.runtimes.set(cfg.key, { config: cfg, client: null, lastError: msg });
+ }
+ }
+ return this.getStatus();
+ } catch (e) {
+ // 表未创建时不阻断启动
+ const msg = e instanceof Error ? e.message : String(e);
+ this.logger.warn(`wecom aibot reload failed (${reason}): ${msg}`);
+ return this.getStatus();
+ } finally {
+ this.starting = false;
+ }
+ }
+
+ private rowToConfig(row: {
+ id: bigint;
+ name: string;
+ role: string;
+ botId: string;
+ secret: string;
+ avatarUrl: string | null;
+ welcome: string | null;
+ permissions: string;
+ enabled: boolean;
+ }): WecomBotRuntimeConfig {
+ const role = (isWecomRole(row.role) ? row.role : 'CUSTOM') as WecomBotRole;
+ return {
+ id: row.id.toString(),
+ key: `db_${row.id.toString()}`,
+ role,
+ name: row.name,
+ enabled: row.enabled,
+ botId: row.botId,
+ secret: row.secret,
+ avatarUrl: row.avatarUrl,
+ welcome: row.welcome?.trim() || DEFAULT_WELCOMES[role],
+ permissions: resolveWecomBotPermissions(role, row.permissions),
+ };
+ }
+
+ private stopAll(reason: string) {
+ for (const [key, rt] of this.runtimes) {
+ if (!rt.client) continue;
+ try {
+ rt.client.removeAllListeners();
+ rt.client.disconnect();
+ this.logger.log(`wecom bot ${key} disconnected (${reason})`);
+ } catch (e) {
+ this.logger.warn(`wecom bot ${key} disconnect error: ${String(e)}`);
+ }
+ }
+ this.runtimes.clear();
+ }
+
+ private async startBot(cfg: WecomBotRuntimeConfig) {
+ const client = new WSClient({
+ botId: cfg.botId,
+ secret: cfg.secret,
+ maxReconnectAttempts: -1,
+ maxAuthFailureAttempts: 5,
+ heartbeatInterval: 30_000,
+ logger: {
+ debug: (msg, ...args) => this.logger.debug(`[${cfg.key}] ${formatSdkLog(msg, args)}`),
+ info: (msg, ...args) => this.logger.log(`[${cfg.key}] ${formatSdkLog(msg, args)}`),
+ warn: (msg, ...args) => this.logger.warn(`[${cfg.key}] ${formatSdkLog(msg, args)}`),
+ error: (msg, ...args) => this.logger.error(`[${cfg.key}] ${formatSdkLog(msg, args)}`),
+ },
+ });
+
+ const rt: BotRuntime = { config: cfg, client, lastError: null };
+ this.runtimes.set(cfg.key, rt);
+
+ client.on('authenticated', () => {
+ rt.lastError = null;
+ this.logger.log(`wecom bot ${cfg.key} authenticated bot=${maskId(cfg.botId)}`);
+ });
+ client.on('disconnected', (reason) => {
+ this.logger.warn(`wecom bot ${cfg.key} disconnected: ${reason || 'unknown'}`);
+ });
+ client.on('error', (err) => {
+ rt.lastError = err instanceof Error ? err.message : String(err);
+ this.logger.error(`wecom bot ${cfg.key} error: ${rt.lastError}`);
+ });
+ client.on('event.enter_chat', (frame: WsFrame) => {
+ void this.handleEnterChat(cfg, client, frame);
+ });
+ client.on('message.text', (frame: WsFrame) => {
+ void this.handleText(cfg, client, frame);
+ });
+ for (const evt of [
+ 'message.image',
+ 'message.file',
+ 'message.voice',
+ 'message.video',
+ 'message.mixed',
+ ] as const) {
+ client.on(evt, (frame: WsFrame) => {
+ void this.replyText(client, frame, '暂仅支持文本消息,请发送「帮助」。');
+ });
+ }
+
+ client.connect();
+ this.logger.log(`wecom bot ${cfg.key} connecting bot=${maskId(cfg.botId)}`);
+ }
+
+ private async handleEnterChat(cfg: WecomBotRuntimeConfig, client: WSClient, frame: WsFrame) {
+ try {
+ await client.replyWelcome(frame, {
+ msgtype: 'text',
+ text: { content: cfg.welcome },
+ });
+ } catch (e) {
+ this.logger.error(`[${cfg.key}] welcome failed: ${String(e)}`);
+ }
+ }
+
+ private async handleText(cfg: WecomBotRuntimeConfig, client: WSClient, frame: WsFrame) {
+ const raw = String(frame.body?.text?.content ?? '').trim();
+ const content = raw.replace(/@[^\s]+\s*/g, '').trim();
+ const wecomUserId = String(frame.body?.from?.userid ?? 'unknown');
+ const lower = content.toLowerCase();
+
+ try {
+ if (!content || lower === '帮助' || lower === 'help' || content === '?' || content === '?') {
+ await this.replyText(client, frame, this.actions.buildHelp(cfg));
+ return;
+ }
+ if (lower === '状态' || lower === 'status' || lower === 'ping') {
+ await this.replyText(client, frame, this.formatStatusMarkdown());
+ return;
+ }
+ const reply = await this.actions.handleCommand(cfg, wecomUserId, content);
+ await this.replyText(client, frame, reply);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ this.logger.error(`[${cfg.key}] handle text failed: ${msg}`);
+ await this.replyText(client, frame, `处理失败:${msg}`);
+ }
+ }
+
+ private formatStatusMarkdown(): string {
+ const st = this.getStatus();
+ const lines = [
+ '**企微机器人状态**',
+ `- 总开关:${st.masterEnabled ? '开' : '关'}(系统设置 → 功能开关)`,
+ '',
+ ];
+ for (const b of st.bots) {
+ lines.push(
+ `**${b.name}**`,
+ `- 角色:${b.role}`,
+ `- 启用/配置/连接:${b.enabled ? '是' : '否'} / ${b.configured ? '是' : '否'} / ${b.connected ? '是' : '否'}`,
+ `- BotID:${b.botIdMasked ?? '—'}`,
+ `- 权限:${b.permissions.join(', ') || '—'}`,
+ b.lastError ? `- 错误:${b.lastError}` : '',
+ '',
+ );
+ }
+ return lines.filter((l, i, arr) => l !== '' || arr[i - 1] !== '').join('\n');
+ }
+
+ private async replyText(client: WSClient, frame: WsFrame, content: string) {
+ const streamId = generateReqId('stream');
+ try {
+ await client.replyStream(frame, streamId, content, true);
+ } catch (e) {
+ this.logger.error(`reply failed: ${String(e)}`);
+ }
+ }
+}
+
+function maskId(id: string): string {
+ if (id.length <= 8) return `${id.slice(0, 2)}***`;
+ return `${id.slice(0, 4)}…${id.slice(-4)}`;
+}
+
+function formatSdkLog(message: string, args: unknown[]): string {
+ if (!args.length) return message;
+ try {
+ return `${message} ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`;
+ } catch {
+ return message;
+ }
+}
diff --git a/server/dukang-api/src/integrations/wecom/wecom-bot-actions.service.ts b/server/dukang-api/src/integrations/wecom/wecom-bot-actions.service.ts
new file mode 100644
index 0000000..ce8b319
--- /dev/null
+++ b/server/dukang-api/src/integrations/wecom/wecom-bot-actions.service.ts
@@ -0,0 +1,459 @@
+import { Inject, Injectable, Logger } from '@nestjs/common';
+import {
+ SUPPORT_TICKET_STATUS_LABELS,
+ SUPPORT_TICKET_TYPE_LABELS,
+ TICKET_TYPE_LABELS,
+ type AfterSaleTicketType,
+ type SupportTicketTypeDto,
+} from '@dukang/shared-types';
+import { PrismaService } from '../../common/prisma/prisma.module';
+import { TicketService } from '../../modules/common/ticket.service';
+import { SupportTicketService } from '../../modules/common/support-ticket.service';
+import { SMS_PROVIDER } from '../integrations.constants';
+import type { ISmsProvider } from '../sms/sms.interface';
+import type { WecomBotRuntimeConfig } from './wecom-bot.types';
+import { wecomBotHasPermission } from './wecom-bot.types';
+import { WecomBotSessionService } from './wecom-bot-session.service';
+import { searchHandbook } from './wecom-handbook';
+
+const SMS_SCENE = 'WECOM_USER_VIEW';
+const PHONE_RE = /^1\d{10}$/;
+
+const TICKET_TYPE_ALIASES: Record = {
+ 仅退款: 'REFUND',
+ 退款: 'REFUND',
+ refund: 'REFUND',
+ 破损补发: 'RESHIPMENT',
+ 补发: 'RESHIPMENT',
+ reshipment: 'RESHIPMENT',
+ 破损退货: 'DAMAGE_RETURN',
+ 退货: 'DAMAGE_RETURN',
+ damage_return: 'DAMAGE_RETURN',
+ 退货退款: 'RETURN_REFUND',
+ return_refund: 'RETURN_REFUND',
+};
+
+const SUPPORT_TYPE_ALIASES: Record = {
+ bug: 'BUG',
+ BUG: 'BUG',
+ 缺陷: 'BUG',
+ 建议: 'SUGGESTION',
+ suggestion: 'SUGGESTION',
+ 其他: 'OTHER',
+ other: 'OTHER',
+};
+
+@Injectable()
+export class WecomBotActionsService {
+ private readonly logger = new Logger(WecomBotActionsService.name);
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly ticketService: TicketService,
+ private readonly supportTicketService: SupportTicketService,
+ private readonly session: WecomBotSessionService,
+ @Inject(SMS_PROVIDER) private readonly sms: ISmsProvider,
+ ) {}
+
+ buildHelp(bot: WecomBotRuntimeConfig): string {
+ const lines = [`**${bot.name}**`, '', '通用:`帮助` · `状态`', ''];
+ if (wecomBotHasPermission(bot, 'ticket.create')) {
+ lines.push(
+ '**售后工单**',
+ '`工单 <订单号> <类型> [备注]`',
+ '类型:仅退款 / 破损补发 / 破损退货 / 退货退款',
+ '例:`工单 DK123 仅退款 用户要求退款`',
+ '',
+ );
+ }
+ if (wecomBotHasPermission(bot, 'user.view_sms')) {
+ lines.push(
+ '**查用户(需短信验证)**',
+ '`查用户 <手机号>` → 向该手机发验证码',
+ '`验证 <验证码>` → 验证通过后展示用户摘要',
+ '',
+ );
+ }
+ if (wecomBotHasPermission(bot, 'delivery.view')) {
+ lines.push('**查快递**', '`快递 <订单号|运单号>`', '');
+ }
+ if (wecomBotHasPermission(bot, 'support_ticket.create')) {
+ lines.push(
+ '**技术支持提单**',
+ '`提单 <标题> [| 详情]`',
+ '例:`提单 BUG 支付回调偶发失败 | 订单号xxx`',
+ '',
+ );
+ }
+ if (wecomBotHasPermission(bot, 'support_ticket.progress')) {
+ lines.push('**开发进度**', '`进度` 最近工单 · `进度 <工单号>` 详情', '');
+ }
+ if (wecomBotHasPermission(bot, 'handbook.query')) {
+ lines.push(
+ '**使用手册**',
+ '`手册` 目录 · `手册 <关键词>` 如:开城、核销、订单、财务',
+ '',
+ );
+ }
+ lines.push(`当前权限:${bot.permissions.join(', ') || '无'}`);
+ return lines.join('\n');
+ }
+
+ async handleCommand(
+ bot: WecomBotRuntimeConfig,
+ wecomUserId: string,
+ text: string,
+ ): Promise {
+ const content = text.trim();
+ if (!content) return this.buildHelp(bot);
+
+ // 查用户 / 验证
+ if (/^(查用户|用户)\s+/i.test(content)) {
+ this.requirePerm(bot, 'user.view_sms');
+ const phone = content.replace(/^(查用户|用户)\s+/i, '').trim();
+ return this.startUserView(bot, wecomUserId, phone);
+ }
+ if (/^(验证|verify)\s+/i.test(content)) {
+ this.requirePerm(bot, 'user.view_sms');
+ const code = content.replace(/^(验证|verify)\s+/i, '').trim();
+ return this.verifyUserView(bot, wecomUserId, code);
+ }
+
+ // 快递
+ if (/^(快递|配送|物流)\s+/i.test(content)) {
+ this.requirePerm(bot, 'delivery.view');
+ const q = content.replace(/^(快递|配送|物流)\s+/i, '').trim();
+ return this.lookupDelivery(q);
+ }
+
+ // 售后工单
+ if (/^(工单|创建工单)\s+/i.test(content)) {
+ this.requirePerm(bot, 'ticket.create');
+ return this.createAfterSaleTicket(content.replace(/^(工单|创建工单)\s+/i, '').trim(), wecomUserId);
+ }
+
+ // 技术支持提单
+ if (/^(提单|技术支持)\s+/i.test(content)) {
+ this.requirePerm(bot, 'support_ticket.create');
+ return this.createSupportTicket(content.replace(/^(提单|技术支持)\s+/i, '').trim(), wecomUserId);
+ }
+
+ // 进度
+ if (/^(进度|开发进度)/i.test(content)) {
+ this.requirePerm(bot, 'support_ticket.progress');
+ const rest = content.replace(/^(进度|开发进度)\s*/i, '').trim();
+ return this.supportProgress(rest);
+ }
+
+ // 手册
+ if (/^(手册|帮助文档|文档)/i.test(content)) {
+ this.requirePerm(bot, 'handbook.query');
+ const q = content.replace(/^(手册|帮助文档|文档)\s*/i, '').trim();
+ return this.queryHandbook(q);
+ }
+
+ // 自然语言手册(仅团队助手有 handbook 权限时)
+ if (wecomBotHasPermission(bot, 'handbook.query') && content.length >= 2) {
+ const hit = searchHandbook(content, 1);
+ if (hit.length) {
+ return formatHandbook(hit);
+ }
+ }
+
+ return `未识别指令。\n\n${this.buildHelp(bot)}`;
+ }
+
+ private requirePerm(bot: WecomBotRuntimeConfig, perm: Parameters[1]) {
+ if (!wecomBotHasPermission(bot, perm)) {
+ throw new Error(`当前机器人无权限:${perm}`);
+ }
+ }
+
+ private async startUserView(bot: WecomBotRuntimeConfig, wecomUserId: string, phone: string) {
+ if (!PHONE_RE.test(phone)) return '请输入 11 位手机号,例如:`查用户 13800138000`';
+ const user = await this.prisma.user.findFirst({
+ where: { phone },
+ select: { id: true, userNo: true, phone: true },
+ });
+ if (!user) return `未找到手机号 ${phone} 对应的用户`;
+
+ await this.session.setPendingPhone(bot.key, wecomUserId, phone);
+ await this.sms.send(phone, SMS_SCENE);
+ return [
+ `已向 **${maskPhone(phone)}** 发送验证码(用户 ${user.userNo})。`,
+ '请回复:`验证 123456`',
+ '验证码约 3 分钟有效。',
+ ].join('\n');
+ }
+
+ private async verifyUserView(bot: WecomBotRuntimeConfig, wecomUserId: string, code: string) {
+ const sess = await this.session.get(bot.key, wecomUserId);
+ if (!sess?.phone) return '请先发送:`查用户 <手机号>`';
+ if (!code) return '请提供验证码,例如:`验证 123456`';
+
+ try {
+ await this.sms.verify(sess.phone, code, SMS_SCENE);
+ } catch {
+ return '验证码错误或已过期,请重新 `查用户`';
+ }
+
+ const user = await this.prisma.user.findFirst({
+ where: { phone: sess.phone },
+ select: {
+ id: true,
+ userNo: true,
+ phone: true,
+ nickname: true,
+ status: true,
+ phoneVerifiedAt: true,
+ createdAt: true,
+ _count: { select: { orders: true } },
+ },
+ });
+ if (!user) return '用户不存在';
+
+ await this.session.markVerified(bot.key, wecomUserId, sess.phone, user.id.toString());
+
+ const coupons = await this.prisma.benefitCoupon.aggregate({
+ where: { userId: user.id, status: 'ACTIVE' },
+ _sum: { balance: true },
+ });
+
+ return [
+ '**用户摘要**(短信验证已通过)',
+ `- 用户号:${user.userNo}`,
+ `- 昵称:${user.nickname || '—'}`,
+ `- 手机:${user.phone}`,
+ `- 手机已验:${user.phoneVerifiedAt ? '是' : '否'}`,
+ `- 状态:${user.status}`,
+ `- 订单数:${user._count.orders}`,
+ `- 权益余额:¥${Number(coupons._sum.balance ?? 0).toFixed(2)}`,
+ `- 注册:${user.createdAt.toISOString().slice(0, 19).replace('T', ' ')}`,
+ ].join('\n');
+ }
+
+ private async lookupDelivery(q: string) {
+ if (!q) return '请提供订单号或运单号,例如:`快递 DK123`';
+
+ const byOrder = await this.prisma.orderDelivery.findMany({
+ where: { order: { orderNo: { contains: q } } },
+ take: 5,
+ orderBy: { updatedAt: 'desc' },
+ include: {
+ order: {
+ select: {
+ orderNo: true,
+ status: true,
+ receiverName: true,
+ receiverPhone: true,
+ deliveryType: true,
+ productName: true,
+ },
+ },
+ },
+ });
+ const byTrack =
+ byOrder.length > 0
+ ? []
+ : await this.prisma.orderDelivery.findMany({
+ where: { trackingNo: { contains: q } },
+ take: 5,
+ orderBy: { updatedAt: 'desc' },
+ include: {
+ order: {
+ select: {
+ orderNo: true,
+ status: true,
+ receiverName: true,
+ receiverPhone: true,
+ deliveryType: true,
+ productName: true,
+ },
+ },
+ },
+ });
+
+ const rows = byOrder.length ? byOrder : byTrack;
+ if (!rows.length) return `未找到与「${q}」匹配的配送单`;
+
+ return rows
+ .map((d, i) => {
+ const o = d.order;
+ return [
+ `**配送 ${i + 1}**`,
+ `- 订单:${o.orderNo}(${o.status})`,
+ `- 商品:${o.productName}`,
+ `- 类型:${o.deliveryType}`,
+ `- 承运:${d.provider}`,
+ `- 运单:${d.trackingNo || '—'}`,
+ `- 第三方单号:${d.providerOrderNo || '—'}`,
+ `- 收货:${o.receiverName} ${maskPhone(o.receiverPhone || '')}`,
+ ].join('\n');
+ })
+ .join('\n\n');
+ }
+
+ private async createAfterSaleTicket(rest: string, wecomUserId: string) {
+ // 订单号 类型 备注
+ const parts = rest.split(/\s+/).filter(Boolean);
+ if (parts.length < 2) {
+ return '格式:`工单 <订单号> <类型> [备注]`\n类型:仅退款 / 破损补发 / 破损退货 / 退货退款';
+ }
+ const orderNo = parts[0];
+ const typeRaw = parts[1];
+ const remark = parts.slice(2).join(' ') || `企微客服创建 by ${wecomUserId}`;
+ const ticketType = TICKET_TYPE_ALIASES[typeRaw] || TICKET_TYPE_ALIASES[typeRaw.toLowerCase()];
+ if (!ticketType) {
+ return `未知类型「${typeRaw}」。可用:仅退款 / 破损补发 / 破损退货 / 退货退款`;
+ }
+
+ const order = await this.prisma.order.findFirst({ where: { orderNo } });
+ if (!order) return `订单不存在:${orderNo}`;
+ if (order.status === 'PENDING_PAY' || order.status === 'CANCELLED') {
+ return '当前订单状态不可创建售后工单';
+ }
+
+ const pending = await this.prisma.commonTicket.findFirst({
+ where: {
+ ticketType: ticketType as never,
+ refType: 'ORDER',
+ refId: order.id,
+ status: { in: ['PENDING', 'OPEN'] },
+ },
+ });
+ if (pending) return `该类型工单已在处理中:${pending.ticketNo}`;
+
+ const ticket = await this.ticketService.create({
+ ticketType,
+ refType: 'ORDER',
+ refId: order.id.toString(),
+ remark: `${remark} [wecom:${wecomUserId}]`,
+ });
+
+ return [
+ '**售后工单已创建**',
+ `- 工单号:${ticket.ticketNo}`,
+ `- 类型:${TICKET_TYPE_LABELS[ticketType]}`,
+ `- 订单:${orderNo}`,
+ `- 状态:${ticket.status}`,
+ ].join('\n');
+ }
+
+ private async createSupportTicket(rest: string, wecomUserId: string) {
+ const m = rest.match(/^(\S+)\s+(.+)$/);
+ if (!m) return '格式:`提单 <标题> [| 详情]`';
+ const typeRaw = m[1];
+ const restTitle = m[2];
+ const ticketType =
+ SUPPORT_TYPE_ALIASES[typeRaw] || SUPPORT_TYPE_ALIASES[typeRaw.toLowerCase()];
+ if (!ticketType) return '类型请使用:BUG / 建议 / 其他';
+
+ const [titlePart, ...contentParts] = restTitle.split('|');
+ const title = titlePart.trim();
+ const content = contentParts.join('|').trim();
+ if (!title) return '请填写标题';
+
+ const creator = await this.resolveCreator(wecomUserId);
+ const ticket = await this.supportTicketService.create(
+ {
+ ticketType,
+ title,
+ content: content || undefined,
+ remark: `企微技术支持机器人`,
+ },
+ creator,
+ );
+
+ return [
+ '**技术支持工单已创建**',
+ `- 工单号:${ticket.ticketNo}`,
+ `- 类型:${SUPPORT_TICKET_TYPE_LABELS[ticketType]}`,
+ `- 状态:${SUPPORT_TICKET_STATUS_LABELS[ticket.status as keyof typeof SUPPORT_TICKET_STATUS_LABELS] || ticket.status}`,
+ `- 标题:${title}`,
+ '等待最高管理员评审。',
+ ].join('\n');
+ }
+
+ private async supportProgress(ticketNo: string) {
+ if (ticketNo) {
+ const ticket = await this.prisma.commonSupportTicket.findFirst({
+ where: { ticketNo: { contains: ticketNo } },
+ });
+ if (!ticket) return `未找到工单:${ticketNo}`;
+ return [
+ `**${ticket.ticketNo}**`,
+ `- 类型:${SUPPORT_TICKET_TYPE_LABELS[ticket.ticketType as SupportTicketTypeDto] || ticket.ticketType}`,
+ `- 状态:${SUPPORT_TICKET_STATUS_LABELS[ticket.status as keyof typeof SUPPORT_TICKET_STATUS_LABELS] || ticket.status}`,
+ `- 标题:${ticket.title}`,
+ `- 创建人:${ticket.creatorName}`,
+ `- 评审人:${ticket.reviewerName || '—'}`,
+ ticket.rejectReason ? `- 驳回原因:${ticket.rejectReason}` : '',
+ `- 更新:${ticket.updatedAt.toISOString().slice(0, 19).replace('T', ' ')}`,
+ ]
+ .filter(Boolean)
+ .join('\n');
+ }
+
+ const items = await this.prisma.commonSupportTicket.findMany({
+ orderBy: { updatedAt: 'desc' },
+ take: 8,
+ });
+ if (!items.length) return '暂无技术支持工单';
+
+ const byStatus = await this.prisma.commonSupportTicket.groupBy({
+ by: ['status'],
+ _count: { _all: true },
+ });
+ const summary = byStatus
+ .map(
+ (s) =>
+ `${SUPPORT_TICKET_STATUS_LABELS[s.status as keyof typeof SUPPORT_TICKET_STATUS_LABELS] || s.status}:${s._count._all}`,
+ )
+ .join(' · ');
+
+ const list = items
+ .map(
+ (t) =>
+ `- ${t.ticketNo} [${SUPPORT_TICKET_STATUS_LABELS[t.status as keyof typeof SUPPORT_TICKET_STATUS_LABELS] || t.status}] ${t.title}`,
+ )
+ .join('\n');
+
+ return [`**开发进度概览**`, summary, '', '**最近工单**', list, '', '详情:`进度 <工单号>`'].join(
+ '\n',
+ );
+ }
+
+ private queryHandbook(q: string) {
+ if (!q) {
+ const catalog = searchHandbook('', 20)
+ .map((e) => `- ${e.title}(关键词:${e.keywords.slice(0, 4).join('、')})`)
+ .join('\n');
+ return `**手册目录**\n${catalog}\n\n查询:\`手册 <关键词>\``;
+ }
+ const hits = searchHandbook(q, 3);
+ if (!hits.length) return `未找到与「${q}」相关的手册内容。可试:开城、核销、订单、财务、工单`;
+ return formatHandbook(hits);
+ }
+
+ private async resolveCreator(wecomUserId: string) {
+ const admin = await this.prisma.hqAccount.findFirst({
+ where: { status: 'ACTIVE' },
+ orderBy: [{ id: 'asc' }],
+ select: { id: true, name: true },
+ });
+ if (!admin) {
+ this.logger.warn('no hq account for wecom support ticket creator');
+ throw new Error('系统未配置 HQ 账号,无法创建技术支持工单');
+ }
+ return { id: admin.id, name: `${admin.name || 'HQ'}(企微:${wecomUserId})` };
+ }
+}
+
+function maskPhone(phone: string): string {
+ if (!phone || phone.length < 7) return phone || '—';
+ return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
+}
+
+function formatHandbook(entries: ReturnType): string {
+ return entries.map((e) => `**${e.title}**\n${e.body}`).join('\n\n---\n\n');
+}
diff --git a/server/dukang-api/src/integrations/wecom/wecom-bot-session.service.ts b/server/dukang-api/src/integrations/wecom/wecom-bot-session.service.ts
new file mode 100644
index 0000000..a8441c1
--- /dev/null
+++ b/server/dukang-api/src/integrations/wecom/wecom-bot-session.service.ts
@@ -0,0 +1,44 @@
+import { Injectable } from '@nestjs/common';
+import { RedisService } from '../../common/redis/redis.service';
+
+const TTL_SECONDS = 30 * 60;
+
+export type WecomUserVerifySession = {
+ phone: string;
+ /** 验证通过后可查看 */
+ verified: boolean;
+ pendingUserId?: string;
+};
+
+@Injectable()
+export class WecomBotSessionService {
+ constructor(private readonly redis: RedisService) {}
+
+ private key(botKey: string, wecomUserId: string) {
+ return `dukang:wecom:session:${botKey}:${wecomUserId}`;
+ }
+
+ async get(botKey: string, wecomUserId: string): Promise {
+ return this.redis.getJson(this.key(botKey, wecomUserId));
+ }
+
+ async setPendingPhone(botKey: string, wecomUserId: string, phone: string) {
+ await this.redis.setJson(
+ this.key(botKey, wecomUserId),
+ { phone, verified: false } satisfies WecomUserVerifySession,
+ TTL_SECONDS,
+ );
+ }
+
+ async markVerified(botKey: string, wecomUserId: string, phone: string, userId: string) {
+ await this.redis.setJson(
+ this.key(botKey, wecomUserId),
+ { phone, verified: true, pendingUserId: userId } satisfies WecomUserVerifySession,
+ TTL_SECONDS,
+ );
+ }
+
+ async clear(botKey: string, wecomUserId: string) {
+ await this.redis.del(this.key(botKey, wecomUserId));
+ }
+}
diff --git a/server/dukang-api/src/integrations/wecom/wecom-bot.types.ts b/server/dukang-api/src/integrations/wecom/wecom-bot.types.ts
new file mode 100644
index 0000000..8910d48
--- /dev/null
+++ b/server/dukang-api/src/integrations/wecom/wecom-bot.types.ts
@@ -0,0 +1,21 @@
+import type { WecomBotPermission, WecomBotRole } from '@dukang/shared-types';
+
+export type WecomBotRuntimeConfig = {
+ id: string;
+ key: string;
+ role: WecomBotRole;
+ name: string;
+ enabled: boolean;
+ botId: string;
+ secret: string;
+ welcome: string;
+ avatarUrl: string | null;
+ permissions: WecomBotPermission[];
+};
+
+export function wecomBotHasPermission(
+ bot: WecomBotRuntimeConfig,
+ permission: WecomBotPermission,
+): boolean {
+ return bot.permissions.includes(permission);
+}
diff --git a/server/dukang-api/src/integrations/wecom/wecom-handbook.ts b/server/dukang-api/src/integrations/wecom/wecom-handbook.ts
new file mode 100644
index 0000000..90a8d98
--- /dev/null
+++ b/server/dukang-api/src/integrations/wecom/wecom-handbook.ts
@@ -0,0 +1,125 @@
+/** 团队助手手册条目(知识库摘要,供关键词检索) */
+export type HandbookEntry = {
+ id: string;
+ title: string;
+ keywords: string[];
+ body: string;
+};
+
+export const WECOM_HANDBOOK_ENTRIES: HandbookEntry[] = [
+ {
+ id: 'overview',
+ title: '系统整体概述',
+ keywords: ['概述', '四端', '整体', '是什么', '介绍'],
+ body: [
+ '杜康好客:购酒 → 1:1 好客权益 → 门店核销。',
+ '四端:用户小程序 / 门店 H5 / 合伙人 H5 / HQ 后台。',
+ '核心:门店结算=核销额×60%;权益永久;核销码 3 分钟。',
+ ].join('\n'),
+ },
+ {
+ id: 'order',
+ title: '订单与履约',
+ keywords: ['订单', '同城', '跨城', '提货', '履约', '小飞侠'],
+ body: [
+ '状态:待付款 → 已付款 → 已完成(30 分钟未付取消)。',
+ '同城≥2瓶:仓配/小飞侠;跨城≥1箱:总部物流到付。',
+ '现场提货:支付后直接已完成并发权益。',
+ 'HQ「订单」可查看详情、填运单;「配送单」维护运单号。',
+ ].join('\n'),
+ },
+ {
+ id: 'redeem',
+ title: '好客权益与核销',
+ keywords: ['权益', '核销', '出码', '扫码', '余额'],
+ body: [
+ '支付成功发放实付 1:1 权益,永久有效。',
+ '用户出码 3 分钟;门店可扫码或手机号+验证码核销。',
+ '直接核销:0 < 金额 ≤ 全部 ACTIVE 余额。',
+ '门店账本按核销额×60% 入账,T+1 出账。',
+ ].join('\n'),
+ },
+ {
+ id: 'city',
+ title: '开城流程',
+ keywords: ['开城', '城市', '合伙人', '仓库', '仓配'],
+ body: [
+ 'HQ「开城」:①新增城市 ②配置合伙人(全城/区域+佣金)③仓库 ④仓配承运商。',
+ '已开城走同城规则;未开城走跨城到付。',
+ '订单佣金按收货区县解析区域/全城合伙人;跨城归总部。',
+ ].join('\n'),
+ },
+ {
+ id: 'store',
+ title: '开店/拓店流程',
+ keywords: ['开店', '拓店', '入驻', '审核', '试核销'],
+ body: [
+ '合伙人三步录入 → 负责人复核 → HQ 审核 → 试核销 100 元 → 正式入驻。',
+ '营业中门店才对 C 端可见。',
+ 'AUTO_APPROVE_STORE 开启时可自动审核(试点)。',
+ ].join('\n'),
+ },
+ {
+ id: 'finance',
+ title: '财务结算',
+ keywords: ['财务', '账单', '提现', '打款', '佣金'],
+ body: [
+ '门店账单:核销×60%,T+1 出账;HQ 确认打款。',
+ '合伙人月账独立确认打款。',
+ '未出账提现受白名单/单日上限等 FIN 护栏。',
+ ].join('\n'),
+ },
+ {
+ id: 'ticket',
+ title: '工单与发票',
+ keywords: ['工单', '售后', '退款', '补发', '发票', '技术支持'],
+ body: [
+ '售后四类型:仅退款 / 破损补发 / 破损退货 / 退货退款 → HQ 工单中心。',
+ '技术支持:BUG/建议/其他,待评审→开发→测试→通过。',
+ '发票:个人/企业 × 普票/专票;2 工作日 SLA。',
+ ].join('\n'),
+ },
+ {
+ id: 'hq-roles',
+ title: 'HQ 角色分工',
+ keywords: ['运营', '财务', '客服', '权限', '角色', 'hq'],
+ body: [
+ '运营:商品/开城/门店/订单/配送/权益。',
+ '财务:门店/合伙人/酒厂账单与打款、发票、酒厂账户。',
+ '客服:用户/订单、售后工单、发票协助。',
+ '超管:权限分配、技术支持评审、系统设置。',
+ ].join('\n'),
+ },
+ {
+ id: 'settings',
+ title: '系统设置',
+ keywords: ['系统设置', 'mock', '短信', '微信', 'oss', '企微'],
+ body: [
+ 'HQ「系统设置」:功能开关、短信、微信、企微机器人、OSS、应用链接、部署、酒厂账户。',
+ '企微可配置客服/技术支持/团队助手三组 BotID+Secret+权限。',
+ '改完可「同步到 env」;密钥类变更后注意重启标识。',
+ ].join('\n'),
+ },
+];
+
+export function searchHandbook(query: string, limit = 3): HandbookEntry[] {
+ const q = query.trim().toLowerCase();
+ if (!q) return WECOM_HANDBOOK_ENTRIES.slice(0, limit);
+
+ const scored = WECOM_HANDBOOK_ENTRIES.map((e) => {
+ let score = 0;
+ const title = e.title.toLowerCase();
+ if (title.includes(q)) score += 10;
+ for (const kw of e.keywords) {
+ const k = kw.toLowerCase();
+ if (q.includes(k) || k.includes(q)) score += 5;
+ }
+ if (e.body.toLowerCase().includes(q)) score += 1;
+ return { e, score };
+ })
+ .filter((x) => x.score > 0)
+ .sort((a, b) => b.score - a.score);
+
+ if (!scored.length) return [];
+ return scored.slice(0, limit).map((x) => x.e);
+}
diff --git a/server/dukang-api/src/integrations/wecom/wecom.module.ts b/server/dukang-api/src/integrations/wecom/wecom.module.ts
new file mode 100644
index 0000000..bac95e4
--- /dev/null
+++ b/server/dukang-api/src/integrations/wecom/wecom.module.ts
@@ -0,0 +1,14 @@
+import { Module, forwardRef } from '@nestjs/common';
+import { CommonModule } from '../../modules/common/common.module';
+import { IntegrationsModule } from '../integrations.module';
+import { WecomAibotService } from './wecom-aibot.service';
+import { WecomBotActionsService } from './wecom-bot-actions.service';
+import { WecomBotSessionService } from './wecom-bot-session.service';
+
+/** 企微多机器人:依赖 Common(工单)+ Integrations(短信),不反向被 Integrations 引用 */
+@Module({
+ imports: [forwardRef(() => CommonModule), forwardRef(() => IntegrationsModule)],
+ providers: [WecomBotSessionService, WecomBotActionsService, WecomAibotService],
+ exports: [WecomAibotService],
+})
+export class WecomModule {}
diff --git a/server/dukang-api/src/modules/ops/admin-system-config.controller.ts b/server/dukang-api/src/modules/ops/admin-system-config.controller.ts
index 3b334b3..4c8bf19 100644
--- a/server/dukang-api/src/modules/ops/admin-system-config.controller.ts
+++ b/server/dukang-api/src/modules/ops/admin-system-config.controller.ts
@@ -17,6 +17,7 @@ import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { SystemConfigService } from '../../common/system-config/system-config.service';
+import { WecomAibotService } from '../../integrations/wecom/wecom-aibot.service';
@Controller('admin/system-config')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@@ -24,6 +25,7 @@ export class AdminSystemConfigController {
constructor(
private readonly systemConfig: SystemConfigService,
private readonly permissions: HqPermissionsResolver,
+ private readonly wecomAibot: WecomAibotService,
) {}
@Get()
@@ -46,7 +48,12 @@ export class AdminSystemConfigController {
async update(@CurrentUser() user: AuthUser, @Body() dto: SystemConfigUpdateRequest) {
const keys = await this.permissions.resolveEffectiveKeys(user.actorId);
const allowedGroups = allowedConfigGroups(keys);
- return this.systemConfig.update(dto, allowedGroups);
+ const result = await this.systemConfig.update(dto, allowedGroups);
+ if (result.updatedKeys.includes('WECOM_AIBOT_ENABLED')) {
+ const wecomStatus = await this.wecomAibot.reload('system-config');
+ return { ...result, wecomAibot: wecomStatus };
+ }
+ return result;
}
@Post('sync-env')
@@ -74,7 +81,7 @@ export class AdminSystemConfigController {
function allowedConfigGroups(permissionKeys: HqPermissionKey[]): string[] | null {
if (SYSTEM_SETTINGS_PERMISSION_KEYS.every((k) => permissionKeys.includes(k))) {
- return null; // 全部
+ return null;
}
return Object.entries(SYSTEM_CONFIG_GROUP_PERMISSION)
.filter(([, perm]) => permissionKeys.includes(perm))
diff --git a/server/dukang-api/src/modules/ops/admin-wecom-bots.controller.ts b/server/dukang-api/src/modules/ops/admin-wecom-bots.controller.ts
new file mode 100644
index 0000000..e503a1a
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-wecom-bots.controller.ts
@@ -0,0 +1,90 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ Param,
+ Post,
+ Put,
+ Query,
+ UseGuards,
+} from '@nestjs/common';
+import type { CreateWecomBotRequest, UpdateWecomBotRequest } 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 { AdminWecomBotsService } from './admin-wecom-bots.service';
+
+@Controller('admin/wecom-bots')
+@UseGuards(HqAuthGuard, HqPermissionGuard)
+@RequireHqPermissions('wecom_bots')
+export class AdminWecomBotsController {
+ constructor(private readonly service: AdminWecomBotsService) {}
+
+ @Get()
+ list(
+ @Query('name') name?: string,
+ @Query('role') role?: string,
+ @Query('enabled') enabled?: string,
+ @Query('page') page?: string,
+ @Query('pageSize') pageSize?: string,
+ ) {
+ return this.service.list({
+ name,
+ role,
+ enabled,
+ page: page ? Number(page) : undefined,
+ pageSize: pageSize ? Number(pageSize) : undefined,
+ });
+ }
+
+ @Post('reload')
+ @HqOperation({
+ action: HqOperationAction.WECOM_BOT_RELOAD,
+ refType: 'WECOM_BOT',
+ batch: true,
+ })
+ reload() {
+ return this.service.reloadRuntime();
+ }
+
+ @Get(':id')
+ detail(@Param('id') id: string) {
+ return this.service.detail(BigInt(id));
+ }
+
+ @Post()
+ @HqOperation({
+ action: HqOperationAction.WECOM_BOT_CREATE,
+ refType: 'WECOM_BOT',
+ includeBody: true,
+ })
+ create(@Body() body: CreateWecomBotRequest) {
+ return this.service.create(body);
+ }
+
+ @Put(':id')
+ @HqOperation({
+ action: HqOperationAction.WECOM_BOT_UPDATE,
+ refType: 'WECOM_BOT',
+ refIdField: 'id',
+ includeBody: true,
+ })
+ update(@Param('id') id: string, @Body() body: UpdateWecomBotRequest) {
+ return this.service.update(BigInt(id), body);
+ }
+
+ @Delete(':id')
+ @HqOperation({
+ action: HqOperationAction.WECOM_BOT_DELETE,
+ refType: 'WECOM_BOT',
+ refIdField: 'id',
+ })
+ remove(@Param('id') id: string) {
+ return this.service.remove(BigInt(id));
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-wecom-bots.service.ts b/server/dukang-api/src/modules/ops/admin-wecom-bots.service.ts
new file mode 100644
index 0000000..7ee3352
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-wecom-bots.service.ts
@@ -0,0 +1,197 @@
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import {
+ WECOM_BOT_ROLES,
+ parseWecomBotPermissions,
+ resolveWecomBotPermissions,
+ type CreateWecomBotRequest,
+ type UpdateWecomBotRequest,
+ type WecomBotDto,
+ type WecomBotPermission,
+ type WecomBotRole,
+} from '@dukang/shared-types';
+import { PrismaService } from '../../common/prisma/prisma.module';
+import { serializeBigInt } from '../../common/decorators/current-user.decorator';
+import { WecomAibotService } from '../../integrations/wecom/wecom-aibot.service';
+
+function isWecomRole(v: string): v is WecomBotRole {
+ return (WECOM_BOT_ROLES as readonly string[]).includes(v);
+}
+
+@Injectable()
+export class AdminWecomBotsService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly wecomAibot: WecomAibotService,
+ ) {}
+
+ async list(query: { name?: string; role?: string; enabled?: string; page?: number; pageSize?: number }) {
+ const page = query.page ?? 1;
+ const pageSize = query.pageSize ?? 20;
+ const where: {
+ name?: { contains: string };
+ role?: string;
+ enabled?: boolean;
+ } = {};
+ if (query.name?.trim()) where.name = { contains: query.name.trim() };
+ if (query.role?.trim()) where.role = query.role.trim();
+ if (query.enabled === 'true' || query.enabled === 'false') {
+ where.enabled = query.enabled === 'true';
+ }
+
+ const [items, total] = await Promise.all([
+ this.prisma.wecomBot.findMany({
+ where,
+ orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ this.prisma.wecomBot.count({ where }),
+ ]);
+
+ return serializeBigInt({
+ items: items.map((row) => this.toDto(row)),
+ total,
+ page,
+ pageSize,
+ runtime: this.wecomAibot.getStatus(),
+ });
+ }
+
+ async detail(id: bigint) {
+ const row = await this.prisma.wecomBot.findUnique({ where: { id } });
+ if (!row) throw new NotFoundException('机器人不存在');
+ return this.toDto(row);
+ }
+
+ async create(dto: CreateWecomBotRequest) {
+ const name = dto.name?.trim();
+ const botId = dto.botId?.trim();
+ const secret = dto.secret?.trim();
+ if (!name) throw new BadRequestException('请填写名称');
+ if (!botId) throw new BadRequestException('请填写 BotID');
+ if (!secret) throw new BadRequestException('请填写 Secret');
+ if (!isWecomRole(dto.role)) throw new BadRequestException('无效角色');
+
+ const exists = await this.prisma.wecomBot.findUnique({ where: { botId } });
+ if (exists) throw new BadRequestException('BotID 已存在');
+
+ const permissions = resolveWecomBotPermissions(dto.role, dto.permissions);
+ const row = await this.prisma.wecomBot.create({
+ data: {
+ name,
+ role: dto.role,
+ botId,
+ secret,
+ avatarUrl: dto.avatarUrl?.trim() || null,
+ welcome: dto.welcome?.trim() || null,
+ permissions: JSON.stringify(permissions),
+ enabled: dto.enabled !== false,
+ sortOrder: dto.sortOrder ?? 0,
+ },
+ });
+ await this.wecomAibot.reload('bot-create');
+ return this.toDto(row);
+ }
+
+ async update(id: bigint, dto: UpdateWecomBotRequest) {
+ const existing = await this.prisma.wecomBot.findUnique({ where: { id } });
+ if (!existing) throw new NotFoundException('机器人不存在');
+
+ const role = dto.role && isWecomRole(dto.role) ? dto.role : (existing.role as WecomBotRole);
+ if (dto.role && !isWecomRole(dto.role)) throw new BadRequestException('无效角色');
+
+ let botId = existing.botId;
+ if (dto.botId !== undefined) {
+ botId = dto.botId.trim();
+ if (!botId) throw new BadRequestException('BotID 不能为空');
+ if (botId !== existing.botId) {
+ const dup = await this.prisma.wecomBot.findUnique({ where: { botId } });
+ if (dup) throw new BadRequestException('BotID 已存在');
+ }
+ }
+
+ let permissionsJson = existing.permissions;
+ if (dto.permissions !== undefined || dto.role !== undefined) {
+ const permissions =
+ dto.permissions !== undefined
+ ? parseWecomBotPermissions(dto.permissions)
+ : resolveWecomBotPermissions(role, existing.permissions);
+ const finalPerms =
+ dto.permissions !== undefined
+ ? permissions.length
+ ? permissions
+ : resolveWecomBotPermissions(role, null)
+ : resolveWecomBotPermissions(role, existing.permissions);
+ permissionsJson = JSON.stringify(finalPerms);
+ }
+
+ const secret =
+ dto.secret !== undefined && dto.secret.trim() ? dto.secret.trim() : existing.secret;
+
+ const row = await this.prisma.wecomBot.update({
+ where: { id },
+ data: {
+ name: dto.name !== undefined ? dto.name.trim() : undefined,
+ role: dto.role,
+ botId,
+ secret,
+ avatarUrl:
+ dto.avatarUrl === undefined ? undefined : dto.avatarUrl?.trim() || null,
+ welcome: dto.welcome === undefined ? undefined : dto.welcome?.trim() || null,
+ permissions: permissionsJson,
+ enabled: dto.enabled,
+ sortOrder: dto.sortOrder,
+ },
+ });
+ await this.wecomAibot.reload('bot-update');
+ return this.toDto(row);
+ }
+
+ async remove(id: bigint) {
+ const existing = await this.prisma.wecomBot.findUnique({ where: { id } });
+ if (!existing) throw new NotFoundException('机器人不存在');
+ await this.prisma.wecomBot.delete({ where: { id } });
+ await this.wecomAibot.reload('bot-delete');
+ return { ok: true };
+ }
+
+ async reloadRuntime() {
+ return this.wecomAibot.reload('manual');
+ }
+
+ private toDto(row: {
+ id: bigint;
+ name: string;
+ role: string;
+ botId: string;
+ secret: string;
+ avatarUrl: string | null;
+ welcome: string | null;
+ permissions: string;
+ enabled: boolean;
+ sortOrder: number;
+ createdAt: Date;
+ updatedAt: Date;
+ }): WecomBotDto {
+ const role = (isWecomRole(row.role) ? row.role : 'CUSTOM') as WecomBotRole;
+ const permissions = resolveWecomBotPermissions(role, row.permissions) as WecomBotPermission[];
+ return {
+ id: row.id.toString(),
+ name: row.name,
+ role,
+ botId: row.botId,
+ secretConfigured: !!row.secret,
+ avatarUrl: row.avatarUrl,
+ welcome: row.welcome,
+ permissions,
+ enabled: row.enabled,
+ sortOrder: row.sortOrder,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ };
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts
index 952c011..4d80748 100644
--- a/server/dukang-api/src/modules/ops/ops.module.ts
+++ b/server/dukang-api/src/modules/ops/ops.module.ts
@@ -44,6 +44,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { BenefitModule } from '../benefit/benefit.module';
import { CommonModule } from '../common/common.module';
import { IntegrationsModule } from '../../integrations/integrations.module';
+import { WecomModule } from '../../integrations/wecom/wecom.module';
import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller';
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
import { AdminProductDetailTemplatesController } from './admin-product-detail-templates.controller';
@@ -59,10 +60,12 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
import { AdminDeployController } from './admin-deploy.controller';
import { AdminDeployService } from './admin-deploy.service';
import { AdminSystemConfigController } from './admin-system-config.controller';
+import { AdminWecomBotsController } from './admin-wecom-bots.controller';
+import { AdminWecomBotsService } from './admin-wecom-bots.service';
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
@Module({
- imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule, StoreModule],
+ imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, RedeemModule, StoreModule],
controllers: [
AdminDashboardController,
AdminDeployController,
@@ -98,6 +101,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
AdminWechatBindingsController,
AdminHqPermissionsController,
AdminSystemConfigController,
+ AdminWecomBotsController,
AdminFulfillmentProvidersController,
],
providers: [
@@ -124,6 +128,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
AdminWechatBindingsService,
AdminHqPermissionsService,
AdminDeployService,
+ AdminWecomBotsService,
SuperAdminGuard,
],
exports: [CityScopeModule],