diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 6d19e71..5924f37 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -42,6 +42,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage'; import WechatBindingsPage from './pages/WechatBindingsPage'; import HqPermissionsPage from './pages/HqPermissionsPage'; import SystemSettingsPage from './pages/SystemSettingsPage'; +import WecomBotsPage from './pages/WecomBotsPage'; function RequireAuth({ children }: { children: React.ReactNode }) { if (!getToken()) return ; @@ -68,6 +69,7 @@ export default function App() { } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 701e89d..ae4df01 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -41,6 +41,7 @@ const MENU_ITEMS: MenuProps['items'] = [ }, { key: '/orders', icon: , label: '订单' }, { key: '/promo-codes', icon: , label: '推广码' }, + { key: '/wecom-bots', icon: , label: '企微机器人' }, { key: 'stores-group', icon: , @@ -135,6 +136,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean { '/product-detail-templates': 'products', '/orders': 'orders', '/promo-codes': 'promo_codes', + '/wecom-bots': 'wecom_bots', 'stores-group': 'stores', '/stores': 'stores', '/store-categories': 'stores', diff --git a/apps/admin-web/src/pages/WecomBotsPage.tsx b/apps/admin-web/src/pages/WecomBotsPage.tsx new file mode 100644 index 0000000..3c808ac --- /dev/null +++ b/apps/admin-web/src/pages/WecomBotsPage.tsx @@ -0,0 +1,444 @@ +import { useEffect, useState } from 'react'; +import { + Avatar, + Button, + Checkbox, + Descriptions, + Drawer, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Select, + Space, + Switch, + Table, + Tag, + Typography, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + WECOM_BOT_PERMISSIONS, + WECOM_BOT_PERMISSION_LABELS, + WECOM_BOT_ROLE_DEFAULT_PERMISSIONS, + WECOM_BOT_ROLE_LABELS, + WECOM_BOT_ROLES, + type WecomBotDto, + type WecomBotPermission, + type WecomBotRole, +} from '@dukang/shared-types'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; +import OssUpload from '../components/OssUpload'; + +type ListRes = { + items: WecomBotDto[]; + total: number; + page: number; + pageSize: number; + runtime?: { + masterEnabled: boolean; + bots: Array<{ id: string; connected: boolean; lastError: string | null }>; + }; +}; + +type FormValues = { + name: string; + role: WecomBotRole; + botId: string; + secret?: string; + avatarUrl?: string; + welcome?: string; + permissions: WecomBotPermission[]; + enabled: boolean; + sortOrder: number; +}; + +export default function WecomBotsPage() { + const [filterForm] = Form.useForm(); + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); + const [detail, setDetail] = useState(null); + const [runtime, setRuntime] = useState(); + const roleWatch = Form.useWatch('role', form); + + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/wecom-bots', + () => { + const qs = new URLSearchParams(); + if (filters.name) qs.set('name', filters.name); + if (filters.role) qs.set('role', filters.role); + if (filters.enabled) qs.set('enabled', filters.enabled); + return qs; + }, + [filters], + ); + + useEffect(() => { + // useAdminList returns items; runtime comes from same API — fetch once for banner + void request('/admin/wecom-bots?page=1&pageSize=1') + .then((res) => setRuntime(res.runtime)) + .catch(() => {}); + }, [data]); + + function openCreate() { + setEditing(null); + form.setFieldsValue({ + name: '', + role: 'CUSTOMER_SERVICE', + botId: '', + secret: '', + avatarUrl: '', + welcome: '', + permissions: [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS.CUSTOMER_SERVICE], + enabled: true, + sortOrder: 0, + }); + setModalOpen(true); + } + + function openEdit(row: WecomBotDto) { + setEditing(row); + form.setFieldsValue({ + name: row.name, + role: row.role, + botId: row.botId, + secret: '', + avatarUrl: row.avatarUrl || '', + welcome: row.welcome || '', + permissions: row.permissions, + enabled: row.enabled, + sortOrder: row.sortOrder, + }); + setModalOpen(true); + } + + async function submit() { + const values = await form.validateFields(); + setSaving(true); + try { + if (editing) { + await request(`/admin/wecom-bots/${editing.id}`, { + method: 'PUT', + body: JSON.stringify({ + name: values.name.trim(), + role: values.role, + botId: values.botId.trim(), + secret: values.secret?.trim() || undefined, + avatarUrl: values.avatarUrl?.trim() || null, + welcome: values.welcome?.trim() || null, + permissions: values.permissions, + enabled: values.enabled, + sortOrder: values.sortOrder, + }), + }); + message.success('已更新'); + } else { + if (!values.secret?.trim()) { + message.error('请填写 Secret'); + return; + } + await request('/admin/wecom-bots', { + method: 'POST', + body: JSON.stringify({ + name: values.name.trim(), + role: values.role, + botId: values.botId.trim(), + secret: values.secret.trim(), + avatarUrl: values.avatarUrl?.trim() || null, + welcome: values.welcome?.trim() || null, + permissions: values.permissions, + enabled: values.enabled, + sortOrder: values.sortOrder, + }), + }); + message.success('已创建'); + } + setModalOpen(false); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + } + + async function remove(id: string) { + try { + await request(`/admin/wecom-bots/${id}`, { method: 'DELETE' }); + message.success('已删除'); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '删除失败'); + } + } + + async function reloadConnections() { + try { + const st = await request<{ + masterEnabled: boolean; + bots: Array<{ id: string; connected: boolean; lastError: string | null }>; + }>('/admin/wecom-bots/reload', { method: 'POST', body: '{}' }); + message.success('已重载长连接'); + setRuntime(st); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '重载失败'); + } + } + + const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b])); + + const columns: ColumnsType = [ + { + title: '头像', + dataIndex: 'avatarUrl', + width: 64, + render: (url: string | null, row) => ( + + {row.name.slice(0, 1)} + + ), + }, + { title: '名称', dataIndex: 'name', width: 140, ellipsis: true }, + { + title: '角色', + dataIndex: 'role', + width: 120, + render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r, + }, + { title: 'BotID', dataIndex: 'botId', width: 160, ellipsis: true }, + { + title: '权限', + dataIndex: 'permissions', + ellipsis: true, + render: (perms: WecomBotPermission[]) => + perms.map((p) => ( + + {WECOM_BOT_PERMISSION_LABELS[p] || p} + + )), + }, + { + title: '启用', + dataIndex: 'enabled', + width: 70, + render: (v: boolean) => {v ? '是' : '否'}, + }, + { + title: '连接', + width: 80, + render: (_, row) => { + const rt = runtimeMap.get(row.id); + if (!runtime?.masterEnabled) return 总开关关; + if (!row.enabled) return 未启用; + return ( + {rt?.connected ? '已连接' : '未连接'} + ); + }, + }, + { title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime }, + { + title: '操作', + width: 180, + fixed: 'right', + render: (_, row) => ( + + + + remove(row.id)}> + + + + ), + }, + ]; + + return ( +
+ +
+ + 企微机器人 + + + 并列创建多个智能机器人,配置 BotID / Secret / 权限 / 角色 / 头像。总开关在「系统设置 → 功能开关」。 + {runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''} + +
+ + + + +
+ +
{ + setFilters(v); + setPage(1); + }} + > + + + + + + + + + +
+ + { + 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],