微信企业机器人创建

This commit is contained in:
2026-07-26 08:16:56 +08:00
parent f053908acb
commit 34532dc9bf
26 changed files with 1963 additions and 4 deletions
+2
View File
@@ -42,6 +42,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
import WechatBindingsPage from './pages/WechatBindingsPage'; import WechatBindingsPage from './pages/WechatBindingsPage';
import HqPermissionsPage from './pages/HqPermissionsPage'; import HqPermissionsPage from './pages/HqPermissionsPage';
import SystemSettingsPage from './pages/SystemSettingsPage'; import SystemSettingsPage from './pages/SystemSettingsPage';
import WecomBotsPage from './pages/WecomBotsPage';
function RequireAuth({ children }: { children: React.ReactNode }) { function RequireAuth({ children }: { children: React.ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />; if (!getToken()) return <Navigate to="/login" replace />;
@@ -68,6 +69,7 @@ export default function App() {
<Route index element={<PromoCodeDetailPage />} /> <Route index element={<PromoCodeDetailPage />} />
<Route path="users" element={<PromoCodeUsersPage />} /> <Route path="users" element={<PromoCodeUsersPage />} />
</Route> </Route>
<Route path="/wecom-bots" element={<WecomBotsPage />} />
<Route path="/products" element={<ProductsPage />} /> <Route path="/products" element={<ProductsPage />} />
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} /> <Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
<Route path="/stores" element={<StoresPage />} /> <Route path="/stores" element={<StoresPage />} />
@@ -41,6 +41,7 @@ const MENU_ITEMS: MenuProps['items'] = [
}, },
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' }, { key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' }, { key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
{ key: '/wecom-bots', icon: <TeamOutlined />, label: '企微机器人' },
{ {
key: 'stores-group', key: 'stores-group',
icon: <ShopOutlined />, icon: <ShopOutlined />,
@@ -135,6 +136,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/product-detail-templates': 'products', '/product-detail-templates': 'products',
'/orders': 'orders', '/orders': 'orders',
'/promo-codes': 'promo_codes', '/promo-codes': 'promo_codes',
'/wecom-bots': 'wecom_bots',
'stores-group': 'stores', 'stores-group': 'stores',
'/stores': 'stores', '/stores': 'stores',
'/store-categories': 'stores', '/store-categories': 'stores',
+444
View File
@@ -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<FormValues>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<WecomBotDto | null>(null);
const [saving, setSaving] = useState(false);
const [detail, setDetail] = useState<WecomBotDto | null>(null);
const [runtime, setRuntime] = useState<ListRes['runtime']>();
const roleWatch = Form.useWatch('role', form);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<WecomBotDto>(
'/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<ListRes>('/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<WecomBotDto> = [
{
title: '头像',
dataIndex: 'avatarUrl',
width: 64,
render: (url: string | null, row) => (
<Avatar src={url || undefined} shape="square" size={40}>
{row.name.slice(0, 1)}
</Avatar>
),
},
{ 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) => (
<Tag key={p} style={{ marginBottom: 2 }}>
{WECOM_BOT_PERMISSION_LABELS[p] || p}
</Tag>
)),
},
{
title: '启用',
dataIndex: 'enabled',
width: 70,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '是' : '否'}</Tag>,
},
{
title: '连接',
width: 80,
render: (_, row) => {
const rt = runtimeMap.get(row.id);
if (!runtime?.masterEnabled) return <Tag></Tag>;
if (!row.enabled) return <Tag></Tag>;
return (
<Tag color={rt?.connected ? 'green' : 'orange'}>{rt?.connected ? '已连接' : '未连接'}</Tag>
);
},
},
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{
title: '操作',
width: 180,
fixed: 'right',
render: (_, row) => (
<Space>
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request<WecomBotDto>(`/admin/wecom-bots/${row.id}`));
}}
>
</Button>
<Popconfirm title="确认删除该机器人?" onConfirm={() => remove(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
BotID / Secret / / /
{runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''}
</Typography.Text>
</div>
<Space>
<Button onClick={() => void reloadConnections()}></Button>
<Button type="primary" onClick={openCreate}>
</Button>
</Space>
</Space>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="name" label="名称">
<Input allowClear placeholder="名称" />
</Form.Item>
<Form.Item name="role" label="角色">
<Select
allowClear
style={{ width: 160 }}
options={WECOM_BOT_ROLES.map((r) => ({ value: r, label: WECOM_BOT_ROLE_LABELS[r] }))}
/>
</Form.Item>
<Form.Item name="enabled" label="启用">
<Select
allowClear
style={{ width: 100 }}
options={[
{ value: 'true', label: '是' },
{ value: 'false', label: '否' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title={editing ? '编辑企微机器人' : '创建企微机器人'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void submit()}
confirmLoading={saving}
width={640}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:客服机器人" maxLength={64} />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select
options={WECOM_BOT_ROLES.map((r) => ({ value: r, label: WECOM_BOT_ROLE_LABELS[r] }))}
onChange={(role: WecomBotRole) => {
form.setFieldValue('permissions', [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[role]]);
}}
/>
</Form.Item>
<Form.Item name="avatarUrl" label="头像">
<OssUpload bizType="WECOM_BOT_AVATAR" />
</Form.Item>
<Form.Item name="botId" label="BotID" rules={[{ required: true, message: '请填写 BotID' }]}>
<Input placeholder="企业微信后台长连接 BotID" />
</Form.Item>
<Form.Item
name="secret"
label="Secret"
rules={editing ? [] : [{ required: true, message: '请填写 Secret' }]}
extra={editing ? '留空表示不修改' : undefined}
>
<Input.Password placeholder={editing ? '留空不修改' : '长连接专用 Secret'} />
</Form.Item>
<Form.Item
name="permissions"
label="权限"
rules={[{ required: true, message: '请至少选择一项权限' }]}
extra={
roleWatch
? `角色默认:${WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[roleWatch as WecomBotRole]?.map((p) => WECOM_BOT_PERMISSION_LABELS[p]).join('、') || '无'}`
: undefined
}
>
<Checkbox.Group
options={WECOM_BOT_PERMISSIONS.map((p) => ({
value: p,
label: WECOM_BOT_PERMISSION_LABELS[p],
}))}
/>
</Form.Item>
<Form.Item name="welcome" label="欢迎语">
<Input.TextArea rows={3} placeholder="进入会话时的欢迎语,可空" />
</Form.Item>
<Space size="large">
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber style={{ width: 120 }} />
</Form.Item>
</Space>
</Form>
</Modal>
<Drawer title="机器人详情" width={480} open={!!detail} onClose={() => setDetail(null)}>
{detail && (
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="头像">
<Avatar src={detail.avatarUrl || undefined} size={64} shape="square">
{detail.name.slice(0, 1)}
</Avatar>
</Descriptions.Item>
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
<Descriptions.Item label="角色">
{WECOM_BOT_ROLE_LABELS[detail.role] || detail.role}
</Descriptions.Item>
<Descriptions.Item label="BotID">{detail.botId}</Descriptions.Item>
<Descriptions.Item label="Secret">
{detail.secretConfigured ? '已配置' : '未配置'}
</Descriptions.Item>
<Descriptions.Item label="权限">
{detail.permissions.map((p) => WECOM_BOT_PERMISSION_LABELS[p] || p).join('、')}
</Descriptions.Item>
<Descriptions.Item label="欢迎语">{detail.welcome || '—'}</Descriptions.Item>
<Descriptions.Item label="启用">{detail.enabled ? '是' : '否'}</Descriptions.Item>
<Descriptions.Item label="排序">{detail.sortOrder}</Descriptions.Item>
<Descriptions.Item label="创建">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新">{fmtTime(detail.updatedAt)}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}
@@ -14,6 +14,7 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'tickets', label: '工单中心', group: '业务' }, { key: 'tickets', label: '工单中心', group: '业务' },
{ key: 'tech_support', label: '技术支持', group: '业务' }, { key: 'tech_support', label: '技术支持', group: '业务' },
{ key: 'invoices', label: '发票管理', group: '业务' }, { key: 'invoices', label: '发票管理', group: '业务' },
{ key: 'wecom_bots', label: '企微机器人', group: '业务' },
{ key: 'resources', label: 'OSS 资源库', group: '业务' }, { key: 'resources', label: 'OSS 资源库', group: '业务' },
{ key: 'logs', label: '日志', group: '业务' }, { key: 'logs', label: '日志', group: '业务' },
{ key: 'hq_permissions', label: '权限分配', group: '管理' }, { key: 'hq_permissions', label: '权限分配', group: '管理' },
@@ -88,6 +89,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
'tickets', 'tickets',
'tech_support', 'tech_support',
'invoices', 'invoices',
'wecom_bots',
'resources', 'resources',
'logs', 'logs',
'system_settings_wechat_mini', 'system_settings_wechat_mini',
+1
View File
@@ -23,4 +23,5 @@ export * from './city-partner';
export * from './city-warehouse'; export * from './city-warehouse';
export * from './fulfillment-provider'; export * from './fulfillment-provider';
export * from './system-config'; export * from './system-config';
export * from './wecom-bot';
export * from './legal'; export * from './legal';
+107
View File
@@ -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<WecomBotPermission, string> = {
'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<WecomBotRole, string> = {
CUSTOMER_SERVICE: '客服机器人',
TECH_SUPPORT: '技术支持机器人',
TEAM_ASSISTANT: '团队助手',
CUSTOM: '自定义',
};
export const WECOM_BOT_ROLE_DEFAULT_PERMISSIONS: Record<WecomBotRole, WecomBotPermission[]> = {
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<string>(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;
};
+36
View File
@@ -429,6 +429,9 @@ importers:
'@prisma/client': '@prisma/client':
specifier: ^5.18.0 specifier: ^5.18.0
version: 5.22.0(prisma@5.22.0) version: 5.22.0(prisma@5.22.0)
'@wecom/aibot-node-sdk':
specifier: ^1.0.7
version: 1.0.7
ali-oss: ali-oss:
specifier: ^6.23.0 specifier: ^6.23.0
version: 6.23.0 version: 6.23.0
@@ -2775,6 +2778,9 @@ packages:
'@webassemblyjs/wast-printer@1.14.1': '@webassemblyjs/wast-printer@1.14.1':
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} 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': '@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@@ -3737,6 +3743,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
events@3.3.0: events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'} engines: {node: '>=0.8.x'}
@@ -6511,6 +6520,18 @@ packages:
resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} 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: xml2js@0.6.2:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
@@ -9358,6 +9379,17 @@ snapshots:
'@webassemblyjs/ast': 1.14.1 '@webassemblyjs/ast': 1.14.1
'@xtuc/long': 4.2.2 '@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/ieee754@1.2.0': {}
'@xtuc/long@4.2.2': {} '@xtuc/long@4.2.2': {}
@@ -10525,6 +10557,8 @@ snapshots:
etag@1.8.1: {} etag@1.8.1: {}
eventemitter3@5.0.4: {}
events@3.3.0: {} events@3.3.0: {}
execa@8.0.1: execa@8.0.1:
@@ -13562,6 +13596,8 @@ snapshots:
imurmurhash: 0.1.4 imurmurhash: 0.1.4
signal-exit: 4.1.0 signal-exit: 4.1.0
ws@8.21.1: {}
xml2js@0.6.2: xml2js@0.6.2:
dependencies: dependencies:
sax: 1.6.0 sax: 1.6.0
+3
View File
@@ -56,6 +56,9 @@ WX_API_V3_KEY=
WX_PLATFORM_CERT= WX_PLATFORM_CERT=
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
WECOM_AIBOT_ENABLED=false
# 腾讯位置服务(逆地理编码,微信定位展示城市) # 腾讯位置服务(逆地理编码,微信定位展示城市)
TENCENT_LBS_KEY= TENCENT_LBS_KEY=
@@ -43,6 +43,9 @@ WX_API_V3_KEY=
WX_PLATFORM_CERT= WX_PLATFORM_CERT=
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay 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_ID=
OSS_ACCESS_KEY_SECRET= OSS_ACCESS_KEY_SECRET=
OSS_BUCKET=dukang-dev OSS_BUCKET=dukang-dev
+1
View File
@@ -30,6 +30,7 @@
"@nestjs/platform-express": "^10.4.0", "@nestjs/platform-express": "^10.4.0",
"@nestjs/schedule": "^6.1.3", "@nestjs/schedule": "^6.1.3",
"@prisma/client": "^5.18.0", "@prisma/client": "^5.18.0",
"@wecom/aibot-node-sdk": "^1.0.7",
"ali-oss": "^6.23.0", "ali-oss": "^6.23.0",
"bullmq": "^5.12.0", "bullmq": "^5.12.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
+20
View File
@@ -350,6 +350,26 @@ model SystemConfig {
@@map("system_config") @@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 { model MockSmsCode {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
phone String @db.VarChar(20) phone String @db.VarChar(20)
+17
View File
@@ -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;
+2
View File
@@ -21,6 +21,7 @@ import { CommonModule } from './modules/common/common.module';
import { HqOperationModule } from './common/hq-operation/hq-operation.module'; import { HqOperationModule } from './common/hq-operation/hq-operation.module';
import { SystemConfigModule } from './common/system-config/system-config.module'; import { SystemConfigModule } from './common/system-config/system-config.module';
import { CallbacksModule } from './callbacks/callbacks.module'; import { CallbacksModule } from './callbacks/callbacks.module';
import { WecomModule } from './integrations/wecom/wecom.module';
@Module({ @Module({
imports: [ imports: [
@@ -50,6 +51,7 @@ import { CallbacksModule } from './callbacks/callbacks.module';
CommonModule, CommonModule,
HqOperationModule, HqOperationModule,
CallbacksModule, CallbacksModule,
WecomModule,
], ],
}) })
export class AppModule {} export class AppModule {}
@@ -72,6 +72,10 @@ export const HqOperationAction = {
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE', PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
PROMO_CODE_UPDATE: 'PROMO_CODE_UPDATE', PROMO_CODE_UPDATE: 'PROMO_CODE_UPDATE',
PROMO_CODE_UPDATE_STATUS: 'PROMO_CODE_UPDATE_STATUS', 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_COMPLETE: 'REDEEM_PENDING_COMPLETE',
REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT', REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT',
DEPLOY_TRIGGER: 'DEPLOY_TRIGGER', DEPLOY_TRIGGER: 'DEPLOY_TRIGGER',
@@ -155,6 +159,10 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码', [HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
[HqOperationAction.PROMO_CODE_UPDATE]: '编辑推广码', [HqOperationAction.PROMO_CODE_UPDATE]: '编辑推广码',
[HqOperationAction.PROMO_CODE_UPDATE_STATUS]: '推广码启停', [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_COMPLETE]: '弱网待处理单-补核销',
[HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回', [HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回',
[HqOperationAction.DEPLOY_TRIGGER]: '触发系统发布', [HqOperationAction.DEPLOY_TRIGGER]: '触发系统发布',
@@ -51,6 +51,14 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
}, },
{ key: 'MOCK_DELIVERY_AUTO', label: 'Mock 配送自动完成', group: G.feature, type: 'boolean', requiresRestart: false }, { 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: '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_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_TEMPLATE_CODE', 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_AUTH_ENABLED',
'WECHAT_PAY_ENABLED', 'WECHAT_PAY_ENABLED',
'OSS_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; ] as const;
export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.key)); export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.key));
@@ -49,6 +49,16 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
TencentLbsProvider, TencentLbsProvider,
{ provide: MAP_PROVIDER, useExisting: 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 {} export class IntegrationsModule {}
@@ -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<WecomBotRole, string> = {
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<string, BotRuntime>();
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<WecomAibotStatus> {
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;
}
}
@@ -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<string, AfterSaleTicketType> = {
退: '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<string, SupportTicketTypeDto> = {
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|建议|其他> <标题> [| 详情]`',
'例:`提单 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<string> {
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<typeof wecomBotHasPermission>[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 '格式:`提单 <BUG|建议|其他> <标题> [| 详情]`';
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<typeof searchHandbook>): string {
return entries.map((e) => `**${e.title}**\n${e.body}`).join('\n\n---\n\n');
}
@@ -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<WecomUserVerifySession | null> {
return this.redis.getJson<WecomUserVerifySession>(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));
}
}
@@ -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);
}
@@ -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);
}
@@ -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 {}
@@ -17,6 +17,7 @@ import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { SystemConfigService } from '../../common/system-config/system-config.service'; import { SystemConfigService } from '../../common/system-config/system-config.service';
import { WecomAibotService } from '../../integrations/wecom/wecom-aibot.service';
@Controller('admin/system-config') @Controller('admin/system-config')
@UseGuards(HqAuthGuard, HqPermissionGuard) @UseGuards(HqAuthGuard, HqPermissionGuard)
@@ -24,6 +25,7 @@ export class AdminSystemConfigController {
constructor( constructor(
private readonly systemConfig: SystemConfigService, private readonly systemConfig: SystemConfigService,
private readonly permissions: HqPermissionsResolver, private readonly permissions: HqPermissionsResolver,
private readonly wecomAibot: WecomAibotService,
) {} ) {}
@Get() @Get()
@@ -46,7 +48,12 @@ export class AdminSystemConfigController {
async update(@CurrentUser() user: AuthUser, @Body() dto: SystemConfigUpdateRequest) { async update(@CurrentUser() user: AuthUser, @Body() dto: SystemConfigUpdateRequest) {
const keys = await this.permissions.resolveEffectiveKeys(user.actorId); const keys = await this.permissions.resolveEffectiveKeys(user.actorId);
const allowedGroups = allowedConfigGroups(keys); 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') @Post('sync-env')
@@ -74,7 +81,7 @@ export class AdminSystemConfigController {
function allowedConfigGroups(permissionKeys: HqPermissionKey[]): string[] | null { function allowedConfigGroups(permissionKeys: HqPermissionKey[]): string[] | null {
if (SYSTEM_SETTINGS_PERMISSION_KEYS.every((k) => permissionKeys.includes(k))) { if (SYSTEM_SETTINGS_PERMISSION_KEYS.every((k) => permissionKeys.includes(k))) {
return null; // 全部 return null;
} }
return Object.entries(SYSTEM_CONFIG_GROUP_PERMISSION) return Object.entries(SYSTEM_CONFIG_GROUP_PERMISSION)
.filter(([, perm]) => permissionKeys.includes(perm)) .filter(([, perm]) => permissionKeys.includes(perm))
@@ -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));
}
}
@@ -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(),
};
}
}
@@ -44,6 +44,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { BenefitModule } from '../benefit/benefit.module'; import { BenefitModule } from '../benefit/benefit.module';
import { CommonModule } from '../common/common.module'; import { CommonModule } from '../common/common.module';
import { IntegrationsModule } from '../../integrations/integrations.module'; import { IntegrationsModule } from '../../integrations/integrations.module';
import { WecomModule } from '../../integrations/wecom/wecom.module';
import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller'; import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller';
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service'; import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
import { AdminProductDetailTemplatesController } from './admin-product-detail-templates.controller'; 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 { AdminDeployController } from './admin-deploy.controller';
import { AdminDeployService } from './admin-deploy.service'; import { AdminDeployService } from './admin-deploy.service';
import { AdminSystemConfigController } from './admin-system-config.controller'; 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'; import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
@Module({ @Module({
imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule, StoreModule], imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, RedeemModule, StoreModule],
controllers: [ controllers: [
AdminDashboardController, AdminDashboardController,
AdminDeployController, AdminDeployController,
@@ -98,6 +101,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
AdminWechatBindingsController, AdminWechatBindingsController,
AdminHqPermissionsController, AdminHqPermissionsController,
AdminSystemConfigController, AdminSystemConfigController,
AdminWecomBotsController,
AdminFulfillmentProvidersController, AdminFulfillmentProvidersController,
], ],
providers: [ providers: [
@@ -124,6 +128,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
AdminWechatBindingsService, AdminWechatBindingsService,
AdminHqPermissionsService, AdminHqPermissionsService,
AdminDeployService, AdminDeployService,
AdminWecomBotsService,
SuperAdminGuard, SuperAdminGuard,
], ],
exports: [CityScopeModule], exports: [CityScopeModule],