Compare commits
5 Commits
ac1e0a9f13
...
2b7ef65cce
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b7ef65cce | |||
| 745c192693 | |||
| 34532dc9bf | |||
| f053908acb | |||
| 959e0b66cb |
@@ -32,6 +32,7 @@ import StoreBillsPage from './pages/StoreBillsPage';
|
||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||
import WineryBillsPage from './pages/WineryBillsPage';
|
||||
import TicketsPage from './pages/TicketsPage';
|
||||
import SupportTicketsPage from './pages/SupportTicketsPage';
|
||||
import InvoicesPage from './pages/InvoicesPage';
|
||||
import UserLogsPage from './pages/UserLogsPage';
|
||||
import HqLogsPage from './pages/HqLogsPage';
|
||||
@@ -41,6 +42,9 @@ 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';
|
||||
import LlmConfigsPage from './pages/LlmConfigsPage';
|
||||
import KnowledgeBasesPage from './pages/KnowledgeBasesPage';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
@@ -67,6 +71,9 @@ export default function App() {
|
||||
<Route index element={<PromoCodeDetailPage />} />
|
||||
<Route path="users" element={<PromoCodeUsersPage />} />
|
||||
</Route>
|
||||
<Route path="/wecom-bots" element={<WecomBotsPage />} />
|
||||
<Route path="/llm-configs" element={<LlmConfigsPage />} />
|
||||
<Route path="/knowledge-bases" element={<KnowledgeBasesPage />} />
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||
<Route path="/stores" element={<StoresPage />} />
|
||||
@@ -92,6 +99,7 @@ export default function App() {
|
||||
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
||||
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
<Route path="/tickets/support" element={<SupportTicketsPage />} />
|
||||
<Route path="/invoices" element={<InvoicesPage />} />
|
||||
<Route path="/logs/users" element={<UserLogsPage />} />
|
||||
<Route path="/logs/stores" element={<StoreLogsPage />} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Layout, Menu, Typography, Button, Space } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
RobotOutlined,
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
ShoppingOutlined,
|
||||
@@ -41,6 +42,9 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
},
|
||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
||||
{ key: '/wecom-bots', icon: <TeamOutlined />, label: '企微机器人' },
|
||||
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
|
||||
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
|
||||
{
|
||||
key: 'stores-group',
|
||||
icon: <ShopOutlined />,
|
||||
@@ -94,7 +98,15 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
|
||||
],
|
||||
},
|
||||
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
|
||||
{
|
||||
key: 'tickets-group',
|
||||
icon: <CarOutlined />,
|
||||
label: '工单',
|
||||
children: [
|
||||
{ key: '/tickets', label: '工单中心' },
|
||||
{ key: '/tickets/support', label: '技术支持' },
|
||||
],
|
||||
},
|
||||
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
|
||||
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
||||
{
|
||||
@@ -115,6 +127,9 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
];
|
||||
|
||||
function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
if (key === 'tickets-group') {
|
||||
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
|
||||
}
|
||||
const map: Record<string, string | 'system_settings_any'> = {
|
||||
'/': 'dashboard',
|
||||
'/users': 'users',
|
||||
@@ -124,6 +139,9 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
'/product-detail-templates': 'products',
|
||||
'/orders': 'orders',
|
||||
'/promo-codes': 'promo_codes',
|
||||
'/wecom-bots': 'wecom_bots',
|
||||
'/llm-configs': 'llm_configs',
|
||||
'/knowledge-bases': 'knowledge_bases',
|
||||
'stores-group': 'stores',
|
||||
'/stores': 'stores',
|
||||
'/store-categories': 'stores',
|
||||
@@ -148,6 +166,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
'/deliveries': 'deliveries',
|
||||
'/deliveries/xiaofeixia': 'deliveries',
|
||||
'/tickets': 'tickets',
|
||||
'/tickets/support': 'tech_support',
|
||||
'/invoices': 'invoices',
|
||||
'/resources': 'resources',
|
||||
'logs-group': 'logs',
|
||||
|
||||
@@ -39,6 +39,11 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
||||
{ value: 'TICKET_APPROVE', label: '工单通过' },
|
||||
{ value: 'TICKET_REJECT', label: '工单驳回' },
|
||||
{ value: 'TICKET_CREATE', label: '创建工单' },
|
||||
{ value: 'SUPPORT_TICKET_CREATE', label: '创建技术支持工单' },
|
||||
{ value: 'SUPPORT_TICKET_APPROVE', label: '技术支持评审通过' },
|
||||
{ value: 'SUPPORT_TICKET_REJECT', label: '技术支持评审驳回' },
|
||||
{ value: 'SUPPORT_TICKET_START_TESTING', label: '技术支持转入测试' },
|
||||
{ value: 'SUPPORT_TICKET_PASS', label: '技术支持测试通过' },
|
||||
{ value: 'INVOICE_CREATE', label: '创建发票申请' },
|
||||
{ value: 'INVOICE_ISSUE', label: '开具发票' },
|
||||
{ value: 'INVOICE_REJECT', label: '驳回发票' },
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
KnowledgeBaseDto,
|
||||
KnowledgeDocumentDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { uploadFileToOss } from '../lib/upload';
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type DocForm = {
|
||||
title: string;
|
||||
contentText?: string;
|
||||
};
|
||||
|
||||
export default function KnowledgeBasesPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [docForm] = Form.useForm<DocForm>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<KnowledgeBaseDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [drawerKb, setDrawerKb] = useState<KnowledgeBaseDto | null>(null);
|
||||
const [docs, setDocs] = useState<KnowledgeDocumentDto[]>([]);
|
||||
const [docsLoading, setDocsLoading] = useState(false);
|
||||
const [docModalOpen, setDocModalOpen] = useState(false);
|
||||
const [docSaving, setDocSaving] = useState(false);
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||||
useAdminList<KnowledgeBaseDto>(
|
||||
'/admin/knowledge-bases',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
const loadDocs = async (kb: KnowledgeBaseDto) => {
|
||||
setDrawerKb(kb);
|
||||
setDocsLoading(true);
|
||||
try {
|
||||
const res = await request<{ items: KnowledgeDocumentDto[] }>(
|
||||
`/admin/knowledge-bases/${kb.id}/documents`,
|
||||
);
|
||||
setDocs(res.items);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setDocsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({ name: '', description: '', enabled: true });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: KnowledgeBaseDto) => {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
enabled: row.enabled,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await request(`/admin/knowledge-bases/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
message.success('已保存');
|
||||
} else {
|
||||
await request('/admin/knowledge-bases', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<KnowledgeBaseDto> = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '说明', dataIndex: 'description', ellipsis: true },
|
||||
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
width: 90,
|
||||
render: (v: boolean) => (v ? <Tag color="green">开</Tag> : <Tag>关</Tag>),
|
||||
},
|
||||
{
|
||||
title: '创建人',
|
||||
dataIndex: 'createdByName',
|
||||
width: 100,
|
||||
render: (v: string | null, row) => v || row.createdByHqAccountId,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
render: (t: string) => fmtTime(t),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 240,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => void loadDocs(row)}>
|
||||
文档
|
||||
</Button>
|
||||
{row.canEditFull ? (
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
{row.canEditFull ? (
|
||||
<Popconfirm
|
||||
title="确认删除知识库及全部文档?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const docColumns: ColumnsType<KnowledgeDocumentDto> = [
|
||||
{ title: '标题', dataIndex: 'title' },
|
||||
{ title: '文件', dataIndex: 'fileName', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: string) =>
|
||||
s === 'READY' ? <Tag color="green">可检索</Tag> : s === 'FAILED' ? <Tag color="red">失败</Tag> : <Tag>无正文</Tag>,
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (t: string) => fmtTime(t),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) =>
|
||||
drawerKb?.canEditFull ? (
|
||||
<Popconfirm
|
||||
title="删除文档?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
message.success('已删除');
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
知识库
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) =>
|
||||
setFilters({
|
||||
name: v.name || '',
|
||||
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Form.Item name="name" label="名称">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建知识库
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑知识库' : '新建知识库'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void onSave()}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="说明">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={drawerKb ? `文档 · ${drawerKb.name}` : '文档'}
|
||||
open={!!drawerKb}
|
||||
width={720}
|
||||
onClose={() => setDrawerKb(null)}
|
||||
extra={
|
||||
drawerKb?.canEditFull ? (
|
||||
<Button type="primary" onClick={() => { docForm.resetFields(); setDocModalOpen(true); }}>
|
||||
上传/粘贴
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={docsLoading}
|
||||
columns={docColumns}
|
||||
dataSource={docs}
|
||||
pagination={false}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="添加知识文档"
|
||||
open={docModalOpen}
|
||||
onCancel={() => setDocModalOpen(false)}
|
||||
confirmLoading={docSaving}
|
||||
onOk={async () => {
|
||||
if (!drawerKb) return;
|
||||
const values = await docForm.validateFields();
|
||||
setDocSaving(true);
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: values.title,
|
||||
contentText: values.contentText || null,
|
||||
}),
|
||||
});
|
||||
message.success('已添加');
|
||||
setDocModalOpen(false);
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setDocSaving(false);
|
||||
}
|
||||
}}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form form={docForm} layout="vertical">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contentText" label="正文(可粘贴)">
|
||||
<Input.TextArea rows={8} placeholder="支持 Markdown / 纯文本" />
|
||||
</Form.Item>
|
||||
<Form.Item label="或上传文本文件">
|
||||
<Upload
|
||||
accept=".txt,.md,.markdown,.csv,.json,.log,text/*"
|
||||
maxCount={1}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
if (!drawerKb) return;
|
||||
try {
|
||||
const raw = file as File;
|
||||
const uploaded = await uploadFileToOss(raw, {
|
||||
bizType: 'KB_DOCUMENT',
|
||||
mediaType: 'FILE',
|
||||
});
|
||||
const title =
|
||||
docForm.getFieldValue('title') || raw.name.replace(/\.[^.]+$/, '');
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
fileName: raw.name,
|
||||
fileUrl: uploaded.url,
|
||||
mimeType: raw.type || null,
|
||||
sizeBytes: raw.size,
|
||||
}),
|
||||
});
|
||||
message.success('上传成功');
|
||||
setDocModalOpen(false);
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
onSuccess?.(uploaded);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
message.error(err.message);
|
||||
onError?.(err);
|
||||
}
|
||||
}}
|
||||
showUploadList={false}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>上传到 OSS 并入库</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
LLM_PROVIDERS,
|
||||
LLM_PROVIDER_LABELS,
|
||||
LLM_PROVIDER_PRESETS,
|
||||
type LlmApiConfigDto,
|
||||
type LlmProvider,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
provider: LlmProvider;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
modelName?: string;
|
||||
temperature?: number | null;
|
||||
maxTokens?: number | null;
|
||||
systemPrompt?: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export default function LlmConfigsPage() {
|
||||
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<LlmApiConfigDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const providerWatch = Form.useWatch('provider', form);
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<LlmApiConfigDto>(
|
||||
'/admin/llm-configs',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providerWatch || editing) return;
|
||||
const preset = LLM_PROVIDER_PRESETS[providerWatch as LlmProvider];
|
||||
if (!preset) return;
|
||||
form.setFieldsValue({
|
||||
baseUrl: preset.defaultBaseUrl || undefined,
|
||||
modelName: preset.defaultModel || undefined,
|
||||
});
|
||||
}, [providerWatch, editing, form]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
provider: 'DEEPSEEK',
|
||||
baseUrl: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultBaseUrl,
|
||||
apiKey: '',
|
||||
modelName: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultModel,
|
||||
temperature: 0.3,
|
||||
maxTokens: 1024,
|
||||
systemPrompt: '',
|
||||
enabled: true,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: LlmApiConfigDto) => {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
provider: row.provider,
|
||||
baseUrl: row.baseUrl,
|
||||
apiKey: '',
|
||||
modelName: row.modelName,
|
||||
temperature: row.temperature,
|
||||
maxTokens: row.maxTokens,
|
||||
systemPrompt: row.systemPrompt || '',
|
||||
enabled: row.enabled,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
if (!editing.canEditFull) {
|
||||
await request(`/admin/llm-configs/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: values.enabled }),
|
||||
});
|
||||
} else {
|
||||
await request(`/admin/llm-configs/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
baseUrl: values.baseUrl,
|
||||
apiKey: values.apiKey || undefined,
|
||||
modelName: values.modelName,
|
||||
temperature: values.temperature ?? null,
|
||||
maxTokens: values.maxTokens ?? null,
|
||||
systemPrompt: values.systemPrompt || null,
|
||||
enabled: values.enabled,
|
||||
}),
|
||||
});
|
||||
}
|
||||
message.success('已保存');
|
||||
} else {
|
||||
await request('/admin/llm-configs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
baseUrl: values.baseUrl,
|
||||
apiKey: values.apiKey,
|
||||
modelName: values.modelName,
|
||||
temperature: values.temperature ?? null,
|
||||
maxTokens: values.maxTokens ?? null,
|
||||
systemPrompt: values.systemPrompt || null,
|
||||
enabled: values.enabled,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<LlmApiConfigDto> = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '提供商',
|
||||
dataIndex: 'provider',
|
||||
width: 120,
|
||||
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
|
||||
},
|
||||
{ title: '模型', dataIndex: 'modelName', ellipsis: true },
|
||||
{
|
||||
title: 'Key',
|
||||
dataIndex: 'apiKeyConfigured',
|
||||
width: 80,
|
||||
render: (v: boolean) => (v ? <Tag color="green">已配</Tag> : <Tag>无</Tag>),
|
||||
},
|
||||
{
|
||||
title: '生效',
|
||||
dataIndex: 'enabled',
|
||||
width: 90,
|
||||
render: (v: boolean, row) => (
|
||||
<Switch
|
||||
checked={v}
|
||||
disabled={!row.isOwner && !row.canEditFull}
|
||||
onChange={async (checked) => {
|
||||
try {
|
||||
await request(`/admin/llm-configs/${row.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: checked }),
|
||||
});
|
||||
message.success(checked ? '已启用' : '已停用');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建人',
|
||||
dataIndex: 'createdByName',
|
||||
width: 100,
|
||||
render: (v: string | null, row) => v || row.createdByHqAccountId,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
render: (t: string) => fmtTime(t),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
{row.canEditFull ? '编辑' : '开关'}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await request<{ reply: string }>(`/admin/llm-configs/${row.id}/test`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
});
|
||||
message.success(res.reply || '测试成功');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
{row.canEditFull ? (
|
||||
<Popconfirm
|
||||
title="确认删除?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/llm-configs/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const fullEdit = !editing || editing.canEditFull;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
语言模型配置
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
非超管仅可见自己创建的配置,且创建后只能改是否生效
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) =>
|
||||
setFilters({
|
||||
name: v.name || '',
|
||||
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Form.Item name="name" label="名称">
|
||||
<Input allowClear placeholder="搜索" />
|
||||
</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.Item>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? (fullEdit ? '编辑语言模型' : '修改是否生效') : '新建语言模型'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void onSave()}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '必填' }]}>
|
||||
<Input disabled={!fullEdit} />
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="提供商" rules={[{ required: true }]}>
|
||||
<Select
|
||||
disabled={!fullEdit}
|
||||
options={LLM_PROVIDERS.map((p) => ({ value: p, label: LLM_PROVIDER_LABELS[p] }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="baseUrl"
|
||||
label="Base URL"
|
||||
rules={[{ required: fullEdit, message: '必填' }]}
|
||||
extra="填主机根地址即可,如 https://api.deepseek.com(不要带 /v1)"
|
||||
>
|
||||
<Input disabled={!fullEdit} placeholder="https://api.deepseek.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="apiKey"
|
||||
label="API Key"
|
||||
rules={editing ? [] : [{ required: true, message: '必填' }]}
|
||||
extra={editing ? '留空表示不修改' : undefined}
|
||||
>
|
||||
<Input.Password disabled={!fullEdit} />
|
||||
</Form.Item>
|
||||
<Form.Item name="modelName" label="模型名" rules={[{ required: fullEdit, message: '必填' }]}>
|
||||
<Input disabled={!fullEdit} />
|
||||
</Form.Item>
|
||||
<Space style={{ display: 'flex' }} size="large">
|
||||
<Form.Item name="temperature" label="Temperature">
|
||||
<InputNumber disabled={!fullEdit} min={0} max={2} step={0.1} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxTokens" label="Max Tokens">
|
||||
<InputNumber disabled={!fullEdit} min={1} max={128000} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="systemPrompt" label="系统提示词">
|
||||
<Input.TextArea disabled={!fullEdit} rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="生效" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
SUPPORT_TICKET_STATUS_LABELS,
|
||||
SUPPORT_TICKET_TYPE_LABELS,
|
||||
type SupportTicketDto,
|
||||
type SupportTicketStatusDto,
|
||||
type SupportTicketTypeDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
const STATUS_COLOR: Record<SupportTicketStatusDto, string> = {
|
||||
PENDING_REVIEW: 'orange',
|
||||
REJECTED: 'red',
|
||||
DEVELOPING: 'blue',
|
||||
TESTING: 'purple',
|
||||
PASSED: 'green',
|
||||
};
|
||||
|
||||
const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map(
|
||||
(value) => ({ value, label: SUPPORT_TICKET_TYPE_LABELS[value] }),
|
||||
);
|
||||
|
||||
const STATUS_OPTIONS = (Object.keys(SUPPORT_TICKET_STATUS_LABELS) as SupportTicketStatusDto[]).map(
|
||||
(value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }),
|
||||
);
|
||||
|
||||
export default function SupportTicketsPage() {
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||||
useAdminList<SupportTicketDto>('/admin/support-tickets', () => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.ticketType) qs.set('ticketType', filters.ticketType);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
}, [filters]);
|
||||
|
||||
const [detail, setDetail] = useState<SupportTicketDto | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [acting, setActing] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [createForm] = Form.useForm<{
|
||||
ticketType: SupportTicketTypeDto;
|
||||
title: string;
|
||||
content?: string;
|
||||
remark?: string;
|
||||
}>();
|
||||
const [rejectForm] = Form.useForm<{ rejectReason: string }>();
|
||||
|
||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
useEffect(() => {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setDetail(await request<SupportTicketDto>(`/admin/support-tickets/${id}`));
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const values = await createForm.validateFields();
|
||||
setCreating(true);
|
||||
try {
|
||||
await request('/admin/support-tickets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ticketType: values.ticketType,
|
||||
title: values.title.trim(),
|
||||
content: values.content?.trim() || undefined,
|
||||
remark: values.remark?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('技术支持工单已创建,等待最高管理员评审');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function approve() {
|
||||
if (!detail) return;
|
||||
setActing(true);
|
||||
try {
|
||||
await request(`/admin/support-tickets/${detail.id}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
message.success('评审通过,已进入开发');
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReject() {
|
||||
if (!detail) return;
|
||||
const values = await rejectForm.validateFields();
|
||||
setActing(true);
|
||||
try {
|
||||
await request(`/admin/support-tickets/${detail.id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rejectReason: values.rejectReason.trim() }),
|
||||
});
|
||||
message.success('已驳回');
|
||||
setRejectOpen(false);
|
||||
rejectForm.resetFields();
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function startTesting() {
|
||||
if (!detail) return;
|
||||
setActing(true);
|
||||
try {
|
||||
await request(`/admin/support-tickets/${detail.id}/start-testing`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
message.success('已转入测试');
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pass() {
|
||||
if (!detail) return;
|
||||
setActing(true);
|
||||
try {
|
||||
await request(`/admin/support-tickets/${detail.id}/pass`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
message.success('测试通过');
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<SupportTicketDto> = [
|
||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'ticketType',
|
||||
width: 90,
|
||||
render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: SupportTicketStatusDto) => (
|
||||
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||
{ title: '创建人', dataIndex: 'creatorName', width: 100 },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openDetail(String(row.id))}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const drawerExtra = (() => {
|
||||
if (!detail) return null;
|
||||
if (detail.status === 'PENDING_REVIEW' && isSuperAdmin) {
|
||||
return (
|
||||
<Space>
|
||||
<Button type="primary" loading={acting} onClick={() => void approve()}>
|
||||
评审通过
|
||||
</Button>
|
||||
<Button danger loading={acting} onClick={() => setRejectOpen(true)}>
|
||||
驳回
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
if (detail.status === 'DEVELOPING') {
|
||||
return (
|
||||
<Button type="primary" loading={acting} onClick={() => void startTesting()}>
|
||||
开发完成 · 转入测试
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (detail.status === 'TESTING') {
|
||||
return (
|
||||
<Button type="primary" loading={acting} onClick={() => void pass()}>
|
||||
测试通过
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
技术支持
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
创建工单
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="ticketType" label="类型">
|
||||
<Select allowClear style={{ width: 120 }} options={TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
筛选
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="技术支持详情"
|
||||
width={520}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={drawerExtra}
|
||||
>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="工单号">{detail.ticketNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">
|
||||
{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLOR[detail.status]}>
|
||||
{SUPPORT_TICKET_STATUS_LABELS[detail.status] ?? detail.status}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="标题">{detail.title}</Descriptions.Item>
|
||||
<Descriptions.Item label="内容">
|
||||
<div style={{ whiteSpace: 'pre-wrap' }}>{detail.content || '—'}</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建人">{detail.creatorName}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="评审人">{detail.reviewerName || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="评审时间">
|
||||
{detail.reviewedAt ? fmtTime(detail.reviewedAt) : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="驳回理由">{detail.rejectReason || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="创建技术支持工单"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => void submitCreate()}
|
||||
confirmLoading={creating}
|
||||
destroyOnClose
|
||||
okText="提交"
|
||||
>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG' }}>
|
||||
<Form.Item
|
||||
name="ticketType"
|
||||
label="类型"
|
||||
rules={[{ required: true, message: '请选择类型' }]}
|
||||
>
|
||||
<Select options={TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="标题"
|
||||
rules={[{ required: true, message: '请填写标题' }]}
|
||||
>
|
||||
<Input placeholder="简要描述问题或建议" maxLength={128} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="详细说明">
|
||||
<Input.TextArea rows={5} placeholder="复现步骤、期望结果等" maxLength={4000} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="可选" maxLength={512} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="驳回技术支持工单"
|
||||
open={rejectOpen}
|
||||
onCancel={() => setRejectOpen(false)}
|
||||
onOk={() => void submitReject()}
|
||||
confirmLoading={acting}
|
||||
destroyOnClose
|
||||
okText="确认驳回"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Form form={rejectForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="rejectReason"
|
||||
label="驳回理由"
|
||||
rules={[{ required: true, message: '请填写驳回理由' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="必填" maxLength={512} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
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 LlmApiConfigOptionDto,
|
||||
type KnowledgeBaseOptionDto,
|
||||
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[];
|
||||
aiEnabled: boolean;
|
||||
llmConfigId?: string | null;
|
||||
knowledgeBaseId?: string | null;
|
||||
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 [llmOptions, setLlmOptions] = useState<LlmApiConfigOptionDto[]>([]);
|
||||
const [kbOptions, setKbOptions] = useState<KnowledgeBaseOptionDto[]>([]);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
void request<{
|
||||
llmConfigs: LlmApiConfigOptionDto[];
|
||||
knowledgeBases: KnowledgeBaseOptionDto[];
|
||||
}>('/admin/wecom-bots/ai-options')
|
||||
.then((res) => {
|
||||
setLlmOptions(res.llmConfigs);
|
||||
setKbOptions(res.knowledgeBases);
|
||||
})
|
||||
.catch(() => {
|
||||
setLlmOptions([]);
|
||||
setKbOptions([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
role: 'CUSTOMER_SERVICE',
|
||||
botId: '',
|
||||
secret: '',
|
||||
avatarUrl: '',
|
||||
welcome: '',
|
||||
permissions: [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS.CUSTOMER_SERVICE],
|
||||
aiEnabled: false,
|
||||
llmConfigId: null,
|
||||
knowledgeBaseId: null,
|
||||
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,
|
||||
aiEnabled: row.aiEnabled,
|
||||
llmConfigId: row.llmConfigId,
|
||||
knowledgeBaseId: row.knowledgeBaseId,
|
||||
enabled: row.enabled,
|
||||
sortOrder: row.sortOrder,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: values.name.trim(),
|
||||
role: values.role,
|
||||
botId: values.botId.trim(),
|
||||
avatarUrl: values.avatarUrl?.trim() || null,
|
||||
welcome: values.welcome?.trim() || null,
|
||||
permissions: values.permissions,
|
||||
aiEnabled: values.aiEnabled,
|
||||
llmConfigId: values.llmConfigId || null,
|
||||
knowledgeBaseId: values.knowledgeBaseId || null,
|
||||
enabled: values.enabled,
|
||||
sortOrder: values.sortOrder,
|
||||
};
|
||||
if (editing) {
|
||||
await request(`/admin/wecom-bots/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
secret: values.secret?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已更新');
|
||||
} else {
|
||||
if (!values.secret?.trim()) {
|
||||
message.error('请填写 Secret');
|
||||
return;
|
||||
}
|
||||
await request('/admin/wecom-bots', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
secret: values.secret.trim(),
|
||||
}),
|
||||
});
|
||||
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: 'AI',
|
||||
width: 100,
|
||||
render: (_, row) =>
|
||||
row.aiEnabled ? (
|
||||
<Tag color="blue">{row.llmConfigName || '已开'}</Tag>
|
||||
) : (
|
||||
<Tag>关</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>
|
||||
<Form.Item
|
||||
name="aiEnabled"
|
||||
label="启用 AI 问答"
|
||||
valuePropName="checked"
|
||||
extra="开启后,未匹配指令的自然语言将调用绑定的语言模型(可挂知识库)"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="llmConfigId" label="语言模型">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="选择已生效的配置"
|
||||
options={llmOptions.map((o) => ({
|
||||
value: o.id,
|
||||
label: `${o.name}(${o.modelName})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="knowledgeBaseId" label="知识库">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="可选,供 AI 检索"
|
||||
options={kbOptions.map((o) => ({
|
||||
value: o.id,
|
||||
label: `${o.name}(${o.documentCount} 篇)`,
|
||||
}))}
|
||||
/>
|
||||
</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="AI 问答">{detail.aiEnabled ? '开' : '关'}</Descriptions.Item>
|
||||
<Descriptions.Item label="语言模型">{detail.llmConfigName || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="知识库">{detail.knowledgeBaseName || '—'}</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>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,11 @@ export const HQ_PERMISSION_CATALOG = [
|
||||
{ key: 'benefit', label: '好客权益', group: '业务' },
|
||||
{ key: 'deliveries', label: '配送单', group: '业务' },
|
||||
{ key: 'tickets', label: '工单中心', group: '业务' },
|
||||
{ key: 'tech_support', label: '技术支持', group: '业务' },
|
||||
{ key: 'invoices', label: '发票管理', group: '业务' },
|
||||
{ key: 'wecom_bots', label: '企微机器人', group: '业务' },
|
||||
{ key: 'llm_configs', label: '语言模型配置', group: '业务' },
|
||||
{ key: 'knowledge_bases', label: '知识库', group: '业务' },
|
||||
{ key: 'resources', label: 'OSS 资源库', group: '业务' },
|
||||
{ key: 'logs', label: '日志', group: '业务' },
|
||||
{ key: 'hq_permissions', label: '权限分配', group: '管理' },
|
||||
@@ -85,7 +89,11 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
||||
'benefit',
|
||||
'deliveries',
|
||||
'tickets',
|
||||
'tech_support',
|
||||
'invoices',
|
||||
'wecom_bots',
|
||||
'llm_configs',
|
||||
'knowledge_bases',
|
||||
'resources',
|
||||
'logs',
|
||||
'system_settings_wechat_mini',
|
||||
@@ -97,9 +105,18 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
||||
'partners',
|
||||
'finance',
|
||||
'benefit',
|
||||
'tech_support',
|
||||
'invoices',
|
||||
'logs',
|
||||
'system_settings_winery_bank',
|
||||
],
|
||||
CUSTOMER_SERVICE: ['dashboard', 'users', 'orders', 'tickets', 'invoices', 'logs'],
|
||||
CUSTOMER_SERVICE: [
|
||||
'dashboard',
|
||||
'users',
|
||||
'orders',
|
||||
'tickets',
|
||||
'tech_support',
|
||||
'invoices',
|
||||
'logs',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from './redeem';
|
||||
export * from './settlement';
|
||||
export * from './ops';
|
||||
export * from './ticket';
|
||||
export * from './support-ticket';
|
||||
export * from './invoice';
|
||||
export * from './user-log';
|
||||
|
||||
@@ -22,4 +23,7 @@ export * from './city-partner';
|
||||
export * from './city-warehouse';
|
||||
export * from './fulfillment-provider';
|
||||
export * from './system-config';
|
||||
export * from './wecom-bot';
|
||||
export * from './llm-config';
|
||||
export * from './knowledge-base';
|
||||
export * from './legal';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export type KnowledgeBaseDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
documentCount: number;
|
||||
createdByHqAccountId: string;
|
||||
createdByName: string | null;
|
||||
isOwner: boolean;
|
||||
canEditFull: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateKnowledgeBaseRequest = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateKnowledgeBaseRequest = {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type KnowledgeDocumentDto = {
|
||||
id: string;
|
||||
knowledgeBaseId: string;
|
||||
title: string;
|
||||
fileName: string | null;
|
||||
fileUrl: string | null;
|
||||
mimeType: string | null;
|
||||
sizeBytes: number | null;
|
||||
/** 是否已抽取可检索正文 */
|
||||
hasContent: boolean;
|
||||
status: 'READY' | 'EMPTY' | 'FAILED';
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateKnowledgeDocumentRequest = {
|
||||
title: string;
|
||||
/** 直贴正文(优先) */
|
||||
contentText?: string | null;
|
||||
fileName?: string | null;
|
||||
fileUrl?: string | null;
|
||||
mimeType?: string | null;
|
||||
sizeBytes?: number | null;
|
||||
};
|
||||
|
||||
export type KnowledgeBaseOptionDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
documentCount: number;
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
/** 语言模型 API 提供商(OpenAI 兼容 Chat Completions) */
|
||||
export const LLM_PROVIDERS = [
|
||||
'DEEPSEEK',
|
||||
'OPENAI',
|
||||
'QWEN',
|
||||
'CUSTOM',
|
||||
] as const;
|
||||
|
||||
export type LlmProvider = (typeof LLM_PROVIDERS)[number];
|
||||
|
||||
export const LLM_PROVIDER_LABELS: Record<LlmProvider, string> = {
|
||||
DEEPSEEK: 'DeepSeek',
|
||||
OPENAI: 'OpenAI',
|
||||
QWEN: '通义千问',
|
||||
CUSTOM: '自定义(OpenAI 兼容)',
|
||||
};
|
||||
|
||||
export const LLM_PROVIDER_PRESETS: Record<
|
||||
LlmProvider,
|
||||
{ defaultBaseUrl: string; defaultModel: string }
|
||||
> = {
|
||||
DEEPSEEK: {
|
||||
defaultBaseUrl: 'https://api.deepseek.com',
|
||||
defaultModel: 'deepseek-chat',
|
||||
},
|
||||
OPENAI: {
|
||||
defaultBaseUrl: 'https://api.openai.com',
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
},
|
||||
QWEN: {
|
||||
defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode',
|
||||
defaultModel: 'qwen-plus',
|
||||
},
|
||||
CUSTOM: {
|
||||
defaultBaseUrl: '',
|
||||
defaultModel: '',
|
||||
},
|
||||
};
|
||||
|
||||
export type LlmApiConfigDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: LlmProvider;
|
||||
baseUrl: string;
|
||||
modelName: string;
|
||||
/** 列表/详情不回明文 */
|
||||
apiKeyConfigured: boolean;
|
||||
temperature: number | null;
|
||||
maxTokens: number | null;
|
||||
systemPrompt: string | null;
|
||||
enabled: boolean;
|
||||
createdByHqAccountId: string;
|
||||
createdByName: string | null;
|
||||
/** 当前登录账号是否为创建人 */
|
||||
isOwner: boolean;
|
||||
/** 非超管仅可改 enabled;超管可改全部 */
|
||||
canEditFull: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateLlmApiConfigRequest = {
|
||||
name: string;
|
||||
provider: LlmProvider;
|
||||
baseUrl?: string;
|
||||
apiKey: string;
|
||||
modelName?: string;
|
||||
temperature?: number | null;
|
||||
maxTokens?: number | null;
|
||||
systemPrompt?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateLlmApiConfigRequest = {
|
||||
/** 非超管仅允许传 enabled */
|
||||
name?: string;
|
||||
provider?: LlmProvider;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
modelName?: string;
|
||||
temperature?: number | null;
|
||||
maxTokens?: number | null;
|
||||
systemPrompt?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type LlmApiConfigOptionDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: LlmProvider;
|
||||
modelName: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/** HQ 技术支持工单类型 */
|
||||
export type SupportTicketTypeDto = 'BUG' | 'SUGGESTION' | 'OTHER';
|
||||
|
||||
export const SUPPORT_TICKET_TYPES = ['BUG', 'SUGGESTION', 'OTHER'] as const;
|
||||
|
||||
export const SUPPORT_TICKET_TYPE_LABELS: Record<SupportTicketTypeDto, string> = {
|
||||
BUG: 'BUG',
|
||||
SUGGESTION: '建议',
|
||||
OTHER: '其他',
|
||||
};
|
||||
|
||||
/** 技术支持工单状态机 */
|
||||
export type SupportTicketStatusDto =
|
||||
| 'PENDING_REVIEW'
|
||||
| 'REJECTED'
|
||||
| 'DEVELOPING'
|
||||
| 'TESTING'
|
||||
| 'PASSED';
|
||||
|
||||
export const SUPPORT_TICKET_STATUSES = [
|
||||
'PENDING_REVIEW',
|
||||
'REJECTED',
|
||||
'DEVELOPING',
|
||||
'TESTING',
|
||||
'PASSED',
|
||||
] as const;
|
||||
|
||||
export const SUPPORT_TICKET_STATUS_LABELS: Record<SupportTicketStatusDto, string> = {
|
||||
PENDING_REVIEW: '待评审',
|
||||
REJECTED: '已驳回',
|
||||
DEVELOPING: '开发',
|
||||
TESTING: '测试',
|
||||
PASSED: '通过',
|
||||
};
|
||||
|
||||
export interface SupportTicketDto {
|
||||
id: string;
|
||||
ticketNo: string;
|
||||
ticketType: SupportTicketTypeDto;
|
||||
status: SupportTicketStatusDto;
|
||||
title: string;
|
||||
content?: string | null;
|
||||
rejectReason?: string | null;
|
||||
creatorId: string;
|
||||
creatorName: string;
|
||||
reviewerId?: string | null;
|
||||
reviewerName?: string | null;
|
||||
reviewedAt?: string | null;
|
||||
remark?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateSupportTicketRequest {
|
||||
ticketType: SupportTicketTypeDto;
|
||||
title: string;
|
||||
content?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface RejectSupportTicketRequest {
|
||||
rejectReason: string;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/** 企业微信智能机器人能力权限 */
|
||||
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[];
|
||||
/** 未匹配指令时是否调用绑定的语言模型 */
|
||||
aiEnabled: boolean;
|
||||
llmConfigId: string | null;
|
||||
llmConfigName: string | null;
|
||||
knowledgeBaseId: string | null;
|
||||
knowledgeBaseName: string | null;
|
||||
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[];
|
||||
aiEnabled?: boolean;
|
||||
llmConfigId?: string | null;
|
||||
knowledgeBaseId?: string | null;
|
||||
enabled?: boolean;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type UpdateWecomBotRequest = {
|
||||
name?: string;
|
||||
role?: WecomBotRole;
|
||||
botId?: string;
|
||||
/** 空或不传表示不修改 */
|
||||
secret?: string;
|
||||
avatarUrl?: string | null;
|
||||
welcome?: string | null;
|
||||
permissions?: WecomBotPermission[];
|
||||
aiEnabled?: boolean;
|
||||
llmConfigId?: string | null;
|
||||
knowledgeBaseId?: string | null;
|
||||
enabled?: boolean;
|
||||
sortOrder?: number;
|
||||
};
|
||||
Generated
+36
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const p = new PrismaClient();
|
||||
|
||||
async function ensureColumn(table, column, ddl) {
|
||||
const cols = await p.$queryRawUnsafe(`SHOW COLUMNS FROM \`${table}\` LIKE '${column}'`);
|
||||
if (!cols.length) {
|
||||
await p.$executeRawUnsafe(ddl);
|
||||
console.log(`added ${table}.${column}`);
|
||||
} else {
|
||||
console.log(`${table}.${column} exists`);
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await ensureColumn(
|
||||
'wecom_bot',
|
||||
'ai_enabled',
|
||||
'ALTER TABLE `wecom_bot` ADD COLUMN `ai_enabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `permissions`',
|
||||
);
|
||||
await ensureColumn(
|
||||
'wecom_bot',
|
||||
'llm_config_id',
|
||||
'ALTER TABLE `wecom_bot` ADD COLUMN `llm_config_id` BIGINT UNSIGNED NULL AFTER `ai_enabled`',
|
||||
);
|
||||
await ensureColumn(
|
||||
'wecom_bot',
|
||||
'knowledge_base_id',
|
||||
'ALTER TABLE `wecom_bot` ADD COLUMN `knowledge_base_id` BIGINT UNSIGNED NULL AFTER `llm_config_id`',
|
||||
);
|
||||
await p.$disconnect();
|
||||
})().catch(async (e) => {
|
||||
console.error(e);
|
||||
await p.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -101,6 +101,31 @@ CREATE TABLE common_ticket (
|
||||
KEY idx_common_ticket_type_status (ticket_type, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通用工单表';
|
||||
|
||||
DROP TABLE IF EXISTS common_support_ticket;
|
||||
CREATE TABLE common_support_ticket (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
ticket_no VARCHAR(32) NOT NULL,
|
||||
ticket_type VARCHAR(32) NOT NULL COMMENT 'BUG|SUGGESTION|OTHER',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT 'PENDING_REVIEW|REJECTED|DEVELOPING|TESTING|PASSED',
|
||||
title VARCHAR(128) NOT NULL,
|
||||
content TEXT DEFAULT NULL,
|
||||
reject_reason VARCHAR(512) DEFAULT NULL,
|
||||
creator_id BIGINT UNSIGNED NOT NULL,
|
||||
creator_name VARCHAR(64) NOT NULL,
|
||||
reviewer_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
reviewer_name VARCHAR(64) DEFAULT NULL,
|
||||
reviewed_at DATETIME(3) DEFAULT NULL,
|
||||
remark VARCHAR(512) DEFAULT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
completed_at DATETIME(3) DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_common_support_ticket_no (ticket_no),
|
||||
KEY idx_common_support_ticket_status (status, created_at),
|
||||
KEY idx_common_support_ticket_type_status (ticket_type, status),
|
||||
KEY idx_common_support_ticket_creator (creator_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HQ技术支持工单';
|
||||
|
||||
DROP TABLE IF EXISTS common_product_item;
|
||||
CREATE TABLE common_product_item (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- LLM / 知识库 / 企微 AI 字段
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `llm_api_config` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(64) NOT NULL,
|
||||
`provider` VARCHAR(32) NOT NULL,
|
||||
`base_url` VARCHAR(512) NOT NULL,
|
||||
`api_key` VARCHAR(512) NOT NULL,
|
||||
`model_name` VARCHAR(128) NOT NULL,
|
||||
`temperature` DECIMAL(3, 2) NULL,
|
||||
`max_tokens` INT NULL,
|
||||
`system_prompt` TEXT NULL,
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_by_hq_account_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `llm_api_config_created_by_hq_account_id_enabled_idx` (`created_by_hq_account_id`, `enabled`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `knowledge_base` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(128) NOT NULL,
|
||||
`description` VARCHAR(512) NULL,
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_by_hq_account_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `knowledge_base_created_by_hq_account_id_enabled_idx` (`created_by_hq_account_id`, `enabled`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `knowledge_document` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`knowledge_base_id` BIGINT UNSIGNED NOT NULL,
|
||||
`title` VARCHAR(256) NOT NULL,
|
||||
`file_name` VARCHAR(256) NULL,
|
||||
`file_url` VARCHAR(1024) NULL,
|
||||
`mime_type` VARCHAR(128) NULL,
|
||||
`size_bytes` INT NULL,
|
||||
`content_text` LONGTEXT NULL,
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'READY',
|
||||
`error_message` VARCHAR(512) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `knowledge_document_knowledge_base_id_idx` (`knowledge_base_id`),
|
||||
CONSTRAINT `knowledge_document_knowledge_base_id_fkey`
|
||||
FOREIGN KEY (`knowledge_base_id`) REFERENCES `knowledge_base` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -88,6 +88,20 @@ enum TicketType {
|
||||
RETURN_REFUND
|
||||
}
|
||||
|
||||
enum SupportTicketType {
|
||||
BUG
|
||||
SUGGESTION
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum SupportTicketStatus {
|
||||
PENDING_REVIEW
|
||||
REJECTED
|
||||
DEVELOPING
|
||||
TESTING
|
||||
PASSED
|
||||
}
|
||||
|
||||
enum InvoiceTitleType {
|
||||
PERSONAL
|
||||
ENTERPRISE
|
||||
@@ -336,6 +350,94 @@ 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
|
||||
/// 未匹配指令时是否调用语言模型
|
||||
aiEnabled Boolean @default(false) @map("ai_enabled")
|
||||
llmConfigId BigInt? @map("llm_config_id") @db.UnsignedBigInt
|
||||
knowledgeBaseId BigInt? @map("knowledge_base_id") @db.UnsignedBigInt
|
||||
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)
|
||||
|
||||
llmConfig LlmApiConfig? @relation(fields: [llmConfigId], references: [id], onDelete: SetNull)
|
||||
knowledgeBase KnowledgeBase? @relation(fields: [knowledgeBaseId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([enabled, sortOrder])
|
||||
@@index([llmConfigId])
|
||||
@@index([knowledgeBaseId])
|
||||
@@map("wecom_bot")
|
||||
}
|
||||
|
||||
/// HQ 语言模型 API 配置(非超管仅可见/可开关自己创建的)
|
||||
model LlmApiConfig {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
name String @db.VarChar(64)
|
||||
provider String @db.VarChar(32)
|
||||
baseUrl String @map("base_url") @db.VarChar(512)
|
||||
apiKey String @map("api_key") @db.VarChar(512)
|
||||
modelName String @map("model_name") @db.VarChar(128)
|
||||
temperature Decimal? @db.Decimal(3, 2)
|
||||
maxTokens Int? @map("max_tokens")
|
||||
systemPrompt String? @map("system_prompt") @db.Text
|
||||
enabled Boolean @default(true)
|
||||
createdByHqAccountId BigInt @map("created_by_hq_account_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
wecomBots WecomBot[]
|
||||
|
||||
@@index([createdByHqAccountId, enabled])
|
||||
@@map("llm_api_config")
|
||||
}
|
||||
|
||||
/// HQ 知识库
|
||||
model KnowledgeBase {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
name String @db.VarChar(128)
|
||||
description String? @db.VarChar(512)
|
||||
enabled Boolean @default(true)
|
||||
createdByHqAccountId BigInt @map("created_by_hq_account_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
documents KnowledgeDocument[]
|
||||
wecomBots WecomBot[]
|
||||
|
||||
@@index([createdByHqAccountId, enabled])
|
||||
@@map("knowledge_base")
|
||||
}
|
||||
|
||||
model KnowledgeDocument {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
knowledgeBaseId BigInt @map("knowledge_base_id") @db.UnsignedBigInt
|
||||
title String @db.VarChar(256)
|
||||
fileName String? @map("file_name") @db.VarChar(256)
|
||||
fileUrl String? @map("file_url") @db.VarChar(1024)
|
||||
mimeType String? @map("mime_type") @db.VarChar(128)
|
||||
sizeBytes Int? @map("size_bytes")
|
||||
contentText String? @map("content_text") @db.LongText
|
||||
status String @default("READY") @db.VarChar(16)
|
||||
errorMessage String? @map("error_message") @db.VarChar(512)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([knowledgeBaseId])
|
||||
@@map("knowledge_document")
|
||||
}
|
||||
|
||||
model MockSmsCode {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
phone String @db.VarChar(20)
|
||||
@@ -440,6 +542,31 @@ model CommonTicket {
|
||||
@@map("common_ticket")
|
||||
}
|
||||
|
||||
/// HQ 内部技术支持工单(BUG / 建议 / 其他)
|
||||
model CommonSupportTicket {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
ticketNo String @unique @map("ticket_no") @db.VarChar(32)
|
||||
ticketType SupportTicketType @map("ticket_type")
|
||||
status SupportTicketStatus @default(PENDING_REVIEW)
|
||||
title String @db.VarChar(128)
|
||||
content String? @db.Text
|
||||
rejectReason String? @map("reject_reason") @db.VarChar(512)
|
||||
creatorId BigInt @map("creator_id") @db.UnsignedBigInt
|
||||
creatorName String @map("creator_name") @db.VarChar(64)
|
||||
reviewerId BigInt? @map("reviewer_id") @db.UnsignedBigInt
|
||||
reviewerName String? @map("reviewer_name") @db.VarChar(64)
|
||||
reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
|
||||
remark String? @db.VarChar(512)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
completedAt DateTime? @map("completed_at") @db.DateTime(3)
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@index([ticketType, status])
|
||||
@@index([creatorId])
|
||||
@@map("common_support_ticket")
|
||||
}
|
||||
|
||||
model CommonProductItem {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
skuCode String @unique @map("sku_code") @db.VarChar(32)
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {}
|
||||
|
||||
@@ -46,6 +46,11 @@ export const HqOperationAction = {
|
||||
TICKET_APPROVE: 'TICKET_APPROVE',
|
||||
TICKET_REJECT: 'TICKET_REJECT',
|
||||
TICKET_CREATE: 'TICKET_CREATE',
|
||||
SUPPORT_TICKET_CREATE: 'SUPPORT_TICKET_CREATE',
|
||||
SUPPORT_TICKET_APPROVE: 'SUPPORT_TICKET_APPROVE',
|
||||
SUPPORT_TICKET_REJECT: 'SUPPORT_TICKET_REJECT',
|
||||
SUPPORT_TICKET_START_TESTING: 'SUPPORT_TICKET_START_TESTING',
|
||||
SUPPORT_TICKET_PASS: 'SUPPORT_TICKET_PASS',
|
||||
INVOICE_CREATE: 'INVOICE_CREATE',
|
||||
INVOICE_ISSUE: 'INVOICE_ISSUE',
|
||||
INVOICE_REJECT: 'INVOICE_REJECT',
|
||||
@@ -67,6 +72,19 @@ 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',
|
||||
LLM_CONFIG_CREATE: 'LLM_CONFIG_CREATE',
|
||||
LLM_CONFIG_UPDATE: 'LLM_CONFIG_UPDATE',
|
||||
LLM_CONFIG_DELETE: 'LLM_CONFIG_DELETE',
|
||||
LLM_CONFIG_TEST: 'LLM_CONFIG_TEST',
|
||||
KNOWLEDGE_BASE_CREATE: 'KNOWLEDGE_BASE_CREATE',
|
||||
KNOWLEDGE_BASE_UPDATE: 'KNOWLEDGE_BASE_UPDATE',
|
||||
KNOWLEDGE_BASE_DELETE: 'KNOWLEDGE_BASE_DELETE',
|
||||
KNOWLEDGE_DOC_CREATE: 'KNOWLEDGE_DOC_CREATE',
|
||||
KNOWLEDGE_DOC_DELETE: 'KNOWLEDGE_DOC_DELETE',
|
||||
REDEEM_PENDING_COMPLETE: 'REDEEM_PENDING_COMPLETE',
|
||||
REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT',
|
||||
DEPLOY_TRIGGER: 'DEPLOY_TRIGGER',
|
||||
@@ -124,6 +142,11 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.TICKET_APPROVE]: '工单通过',
|
||||
[HqOperationAction.TICKET_REJECT]: '工单驳回',
|
||||
[HqOperationAction.TICKET_CREATE]: '创建工单',
|
||||
[HqOperationAction.SUPPORT_TICKET_CREATE]: '创建技术支持工单',
|
||||
[HqOperationAction.SUPPORT_TICKET_APPROVE]: '技术支持评审通过',
|
||||
[HqOperationAction.SUPPORT_TICKET_REJECT]: '技术支持评审驳回',
|
||||
[HqOperationAction.SUPPORT_TICKET_START_TESTING]: '技术支持转入测试',
|
||||
[HqOperationAction.SUPPORT_TICKET_PASS]: '技术支持测试通过',
|
||||
[HqOperationAction.INVOICE_CREATE]: '创建发票申请',
|
||||
[HqOperationAction.INVOICE_ISSUE]: '开具发票',
|
||||
[HqOperationAction.INVOICE_REJECT]: '驳回发票',
|
||||
@@ -145,6 +168,19 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[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.LLM_CONFIG_CREATE]: '创建语言模型配置',
|
||||
[HqOperationAction.LLM_CONFIG_UPDATE]: '更新语言模型配置',
|
||||
[HqOperationAction.LLM_CONFIG_DELETE]: '删除语言模型配置',
|
||||
[HqOperationAction.LLM_CONFIG_TEST]: '测试语言模型配置',
|
||||
[HqOperationAction.KNOWLEDGE_BASE_CREATE]: '创建知识库',
|
||||
[HqOperationAction.KNOWLEDGE_BASE_UPDATE]: '更新知识库',
|
||||
[HqOperationAction.KNOWLEDGE_BASE_DELETE]: '删除知识库',
|
||||
[HqOperationAction.KNOWLEDGE_DOC_CREATE]: '上传知识库文档',
|
||||
[HqOperationAction.KNOWLEDGE_DOC_DELETE]: '删除知识库文档',
|
||||
[HqOperationAction.REDEEM_PENDING_COMPLETE]: '弱网待处理单-补核销',
|
||||
[HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回',
|
||||
[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: '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));
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
|
||||
const MAX_CONTEXT_CHARS = 6000;
|
||||
|
||||
@Injectable()
|
||||
export class KnowledgeRetrievalService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** 简易关键词命中:按文档正文包含查询词打分,拼进上下文 */
|
||||
async buildContext(knowledgeBaseId: bigint, query: string): Promise<string> {
|
||||
const kb = await this.prisma.knowledgeBase.findUnique({
|
||||
where: { id: knowledgeBaseId },
|
||||
select: { id: true, enabled: true, name: true },
|
||||
});
|
||||
if (!kb?.enabled) return '';
|
||||
|
||||
const docs = await this.prisma.knowledgeDocument.findMany({
|
||||
where: {
|
||||
knowledgeBaseId,
|
||||
status: 'READY',
|
||||
contentText: { not: null },
|
||||
},
|
||||
select: { title: true, contentText: true },
|
||||
take: 50,
|
||||
});
|
||||
if (!docs.length) return '';
|
||||
|
||||
const tokens = tokenize(query);
|
||||
const scored = docs
|
||||
.map((d) => {
|
||||
const body = d.contentText || '';
|
||||
let score = 0;
|
||||
for (const t of tokens) {
|
||||
if (body.includes(t) || d.title.includes(t)) score += 1;
|
||||
}
|
||||
if (!tokens.length) score = 1;
|
||||
return { title: d.title, body, score };
|
||||
})
|
||||
.filter((x) => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const picked = scored.length ? scored.slice(0, 5) : docs.slice(0, 3).map((d) => ({
|
||||
title: d.title,
|
||||
body: d.contentText || '',
|
||||
score: 0,
|
||||
}));
|
||||
|
||||
let out = `【知识库:${kb.name}】\n`;
|
||||
for (const p of picked) {
|
||||
const chunk = `### ${p.title}\n${p.body}\n\n`;
|
||||
if (out.length + chunk.length > MAX_CONTEXT_CHARS) {
|
||||
out += chunk.slice(0, Math.max(0, MAX_CONTEXT_CHARS - out.length));
|
||||
break;
|
||||
}
|
||||
out += chunk;
|
||||
}
|
||||
return out.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function tokenize(q: string): string[] {
|
||||
return q
|
||||
.split(/[\s,,。;;、!?!?\-_/\\]+/)
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter((s) => s.length >= 2)
|
||||
.slice(0, 12);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export type LlmChatMessage = { role: 'system' | 'user' | 'assistant'; content: string };
|
||||
|
||||
export type LlmChatParams = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
messages: LlmChatMessage[];
|
||||
temperature?: number | null;
|
||||
maxTokens?: number | null;
|
||||
};
|
||||
|
||||
/** 规范化 OpenAI 兼容根地址:去掉末尾 / 与重复的 /v1 */
|
||||
export function normalizeLlmBaseUrl(raw: string): string {
|
||||
let base = String(raw || '').trim().replace(/\/+$/, '');
|
||||
// 用户常填 https://api.deepseek.com/v1 ,避免拼成 /v1/v1/chat/completions
|
||||
if (/\/v1$/i.test(base)) {
|
||||
base = base.replace(/\/v1$/i, '');
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function buildLlmChatCompletionsUrl(baseUrl: string): string {
|
||||
return `${normalizeLlmBaseUrl(baseUrl)}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LlmChatClient {
|
||||
private readonly logger = new Logger(LlmChatClient.name);
|
||||
|
||||
async chat(params: LlmChatParams): Promise<string> {
|
||||
const url = buildLlmChatCompletionsUrl(params.baseUrl);
|
||||
const body: Record<string, unknown> = {
|
||||
model: params.model,
|
||||
messages: params.messages,
|
||||
stream: false,
|
||||
};
|
||||
if (params.temperature != null && !Number.isNaN(params.temperature)) {
|
||||
body.temperature = params.temperature;
|
||||
}
|
||||
if (params.maxTokens != null && params.maxTokens > 0) {
|
||||
body.max_tokens = params.maxTokens;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`llm chat failed ${res.status} ${url}: ${text.slice(0, 400)}`);
|
||||
throw new Error(`语言模型调用失败(HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
let json: {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
error?: { message?: string };
|
||||
};
|
||||
try {
|
||||
json = JSON.parse(text) as typeof json;
|
||||
} catch {
|
||||
throw new Error('语言模型返回非 JSON');
|
||||
}
|
||||
if (json.error?.message) throw new Error(json.error.message);
|
||||
const content = json.choices?.[0]?.message?.content?.trim();
|
||||
if (!content) throw new Error('语言模型未返回内容');
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LlmChatClient } from './llm-chat.client';
|
||||
import { KnowledgeRetrievalService } from './knowledge-retrieval.service';
|
||||
|
||||
@Module({
|
||||
providers: [LlmChatClient, KnowledgeRetrievalService],
|
||||
exports: [LlmChatClient, KnowledgeRetrievalService],
|
||||
})
|
||||
export class LlmModule {}
|
||||
@@ -0,0 +1,337 @@
|
||||
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 { WecomBotAiService } from './wecom-bot-ai.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,
|
||||
private readonly ai: WecomBotAiService,
|
||||
) {}
|
||||
|
||||
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;
|
||||
aiEnabled: boolean;
|
||||
llmConfigId: bigint | null;
|
||||
knowledgeBaseId: bigint | null;
|
||||
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),
|
||||
aiEnabled: row.aiEnabled,
|
||||
llmConfigId: row.llmConfigId?.toString() ?? null,
|
||||
knowledgeBaseId: row.knowledgeBaseId?.toString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
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 useAi = cfg.aiEnabled && !!cfg.llmConfigId;
|
||||
const reply = await this.actions.handleCommand(cfg, wecomUserId, content, {
|
||||
skipNaturalFallback: useAi,
|
||||
});
|
||||
if (reply != null) {
|
||||
await this.replyText(client, frame, reply);
|
||||
return;
|
||||
}
|
||||
if (useAi) {
|
||||
const aiReply = await this.ai.replyIfConfigured(cfg, content);
|
||||
await this.replyText(
|
||||
client,
|
||||
frame,
|
||||
aiReply ?? `未识别指令。\n\n${this.actions.buildHelp(cfg)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.replyText(client, frame, `未识别指令。\n\n${this.actions.buildHelp(cfg)}`);
|
||||
} 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,472 @@
|
||||
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(
|
||||
'**使用手册**',
|
||||
'`手册` 目录 · `手册 <关键词>` 如:开城、核销、订单、财务',
|
||||
'',
|
||||
);
|
||||
}
|
||||
if (bot.aiEnabled && bot.llmConfigId) {
|
||||
lines.push(
|
||||
'**智能问答**',
|
||||
'未匹配指令的自然语言将交由绑定的语言模型回答(可挂知识库)',
|
||||
'',
|
||||
);
|
||||
}
|
||||
lines.push(`当前权限:${bot.permissions.join(', ') || '无'}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async handleCommand(
|
||||
bot: WecomBotRuntimeConfig,
|
||||
wecomUserId: string,
|
||||
text: string,
|
||||
opts?: { skipNaturalFallback?: boolean },
|
||||
): Promise<string | null> {
|
||||
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 权限时;开启 AI 时改由模型+知识库回答)
|
||||
if (
|
||||
!opts?.skipNaturalFallback &&
|
||||
wecomBotHasPermission(bot, 'handbook.query') &&
|
||||
content.length >= 2
|
||||
) {
|
||||
const hit = searchHandbook(content, 1);
|
||||
if (hit.length) {
|
||||
return formatHandbook(hit);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts?.skipNaturalFallback) return null;
|
||||
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,66 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { LlmChatClient } from '../llm/llm-chat.client';
|
||||
import { KnowledgeRetrievalService } from '../llm/knowledge-retrieval.service';
|
||||
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
|
||||
|
||||
const DEFAULT_SYSTEM = [
|
||||
'你是杜康好客企业内部助手。',
|
||||
'优先依据提供的知识库内容回答;知识库未覆盖时如实说明不确定。',
|
||||
'不要编造订单号、金额、权限;涉及写操作请引导用户使用指令(如「帮助」)。',
|
||||
'回答简洁,使用中文。',
|
||||
].join('');
|
||||
|
||||
@Injectable()
|
||||
export class WecomBotAiService {
|
||||
private readonly logger = new Logger(WecomBotAiService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly llm: LlmChatClient,
|
||||
private readonly kb: KnowledgeRetrievalService,
|
||||
) {}
|
||||
|
||||
async replyIfConfigured(bot: WecomBotRuntimeConfig, userText: string): Promise<string | null> {
|
||||
if (!bot.aiEnabled || !bot.llmConfigId) return null;
|
||||
|
||||
const cfg = await this.prisma.llmApiConfig.findUnique({
|
||||
where: { id: BigInt(bot.llmConfigId) },
|
||||
});
|
||||
if (!cfg?.enabled) {
|
||||
return '已开启 AI,但绑定的语言模型未启用或已删除。请在 HQ「企微机器人」检查配置。';
|
||||
}
|
||||
|
||||
let kbBlock = '';
|
||||
if (bot.knowledgeBaseId) {
|
||||
try {
|
||||
kbBlock = await this.kb.buildContext(BigInt(bot.knowledgeBaseId), userText);
|
||||
} catch (e) {
|
||||
this.logger.warn(`kb retrieve failed: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const systemParts = [
|
||||
cfg.systemPrompt?.trim() || DEFAULT_SYSTEM,
|
||||
kbBlock ? `\n\n以下为知识库检索片段:\n${kbBlock}` : '',
|
||||
];
|
||||
|
||||
try {
|
||||
return await this.llm.chat({
|
||||
baseUrl: cfg.baseUrl,
|
||||
apiKey: cfg.apiKey,
|
||||
model: cfg.modelName,
|
||||
temperature: cfg.temperature != null ? Number(cfg.temperature) : 0.3,
|
||||
maxTokens: cfg.maxTokens ?? 1024,
|
||||
messages: [
|
||||
{ role: 'system', content: systemParts.join('') },
|
||||
{ role: 'user', content: userText },
|
||||
],
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(`wecom ai reply failed: ${msg}`);
|
||||
return `AI 回复失败:${msg}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,24 @@
|
||||
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[];
|
||||
aiEnabled: boolean;
|
||||
llmConfigId: string | null;
|
||||
knowledgeBaseId: string | null;
|
||||
};
|
||||
|
||||
export function wecomBotHasPermission(
|
||||
bot: WecomBotRuntimeConfig,
|
||||
permission: WecomBotPermission,
|
||||
): boolean {
|
||||
return bot.permissions.includes(permission);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/** 团队助手手册条目(知识库摘要,供关键词检索) */
|
||||
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、应用链接、部署、酒厂账户。',
|
||||
'企微机器人在独立菜单「企微机器人」维护;总开关在功能开关「启用企微机器人长连接」。',
|
||||
'角色:客服 / 技术支持 / 团队助手;指令式,不依赖大模型。发「帮助」看命令。',
|
||||
'改完可「同步到 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,25 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { CommonModule } from '../../modules/common/common.module';
|
||||
import { IntegrationsModule } from '../integrations.module';
|
||||
import { LlmModule } from '../llm/llm.module';
|
||||
import { WecomAibotService } from './wecom-aibot.service';
|
||||
import { WecomBotActionsService } from './wecom-bot-actions.service';
|
||||
import { WecomBotAiService } from './wecom-bot-ai.service';
|
||||
import { WecomBotSessionService } from './wecom-bot-session.service';
|
||||
|
||||
/** 企微多机器人:依赖 Common(工单)+ Integrations(短信)+ Llm,不反向被 Integrations 引用 */
|
||||
@Module({
|
||||
imports: [
|
||||
forwardRef(() => CommonModule),
|
||||
forwardRef(() => IntegrationsModule),
|
||||
LlmModule,
|
||||
],
|
||||
providers: [
|
||||
WecomBotSessionService,
|
||||
WecomBotActionsService,
|
||||
WecomBotAiService,
|
||||
WecomAibotService,
|
||||
],
|
||||
exports: [WecomAibotService],
|
||||
})
|
||||
export class WecomModule {}
|
||||
@@ -6,6 +6,7 @@ import { SystemConfigModule } from '../../common/system-config/system-config.mod
|
||||
import { ResourceService } from './resource.service';
|
||||
import { EventService } from './event.service';
|
||||
import { TicketService } from './ticket.service';
|
||||
import { SupportTicketService } from './support-ticket.service';
|
||||
import { ThirdPartyLogService } from './third-party-log.service';
|
||||
import { ResourceController } from './resource.controller';
|
||||
import { EventController } from './event.controller';
|
||||
@@ -25,7 +26,14 @@ import { WechatLocationService } from './wechat-location.service';
|
||||
WechatController,
|
||||
ClientConfigController,
|
||||
],
|
||||
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService, WechatLocationService],
|
||||
exports: [ResourceService, EventService, TicketService],
|
||||
providers: [
|
||||
ResourceService,
|
||||
EventService,
|
||||
TicketService,
|
||||
SupportTicketService,
|
||||
ThirdPartyLogService,
|
||||
WechatLocationService,
|
||||
],
|
||||
exports: [ResourceService, EventService, TicketService, SupportTicketService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
export class SupportTicketListQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(['BUG', 'SUGGESTION', 'OTHER'])
|
||||
ticketType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['PENDING_REVIEW', 'REJECTED', 'DEVELOPING', 'TESTING', 'PASSED'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number = 20;
|
||||
}
|
||||
|
||||
export class CreateSupportTicketDto {
|
||||
@IsIn(['BUG', 'SUGGESTION', 'OTHER'])
|
||||
ticketType: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(128)
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export class RejectSupportTicketDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(512)
|
||||
rejectReason: string;
|
||||
}
|
||||
|
||||
export class SupportTicketRemarkDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
remark?: string;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { SupportTicketStatus, SupportTicketType } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type {
|
||||
CreateSupportTicketDto,
|
||||
RejectSupportTicketDto,
|
||||
SupportTicketListQueryDto,
|
||||
SupportTicketRemarkDto,
|
||||
} from './dto/support-ticket.dto';
|
||||
|
||||
function generateSupportTicketNo() {
|
||||
return `ST${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SupportTicketService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(
|
||||
dto: CreateSupportTicketDto,
|
||||
creator: { id: bigint; name: string },
|
||||
) {
|
||||
const ticket = await this.prisma.commonSupportTicket.create({
|
||||
data: {
|
||||
ticketNo: generateSupportTicketNo(),
|
||||
ticketType: dto.ticketType as SupportTicketType,
|
||||
status: 'PENDING_REVIEW',
|
||||
title: dto.title.trim(),
|
||||
content: dto.content?.trim() || null,
|
||||
remark: dto.remark?.trim() || null,
|
||||
creatorId: creator.id,
|
||||
creatorName: creator.name,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(ticket);
|
||||
}
|
||||
|
||||
async list(query: SupportTicketListQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.CommonSupportTicketWhereInput = {};
|
||||
if (query.ticketType) {
|
||||
where.ticketType = query.ticketType as SupportTicketType;
|
||||
}
|
||||
if (query.status) {
|
||||
where.status = query.status as SupportTicketStatus;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.commonSupportTicket.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.commonSupportTicket.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const ticket = await this.prisma.commonSupportTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('技术支持工单不存在');
|
||||
return serializeBigInt(ticket);
|
||||
}
|
||||
|
||||
private async getOrThrow(id: bigint) {
|
||||
const ticket = await this.prisma.commonSupportTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('技术支持工单不存在');
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/** 最高管理员评审通过 → 进入开发 */
|
||||
async approve(
|
||||
id: bigint,
|
||||
reviewer: { id: bigint; name: string },
|
||||
dto?: SupportTicketRemarkDto,
|
||||
) {
|
||||
const ticket = await this.getOrThrow(id);
|
||||
if (ticket.status !== 'PENDING_REVIEW') {
|
||||
throw new BadRequestException('仅待评审工单可通过评审');
|
||||
}
|
||||
const updated = await this.prisma.commonSupportTicket.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'DEVELOPING',
|
||||
reviewerId: reviewer.id,
|
||||
reviewerName: reviewer.name,
|
||||
reviewedAt: new Date(),
|
||||
remark: dto?.remark?.trim() || ticket.remark,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
/** 最高管理员评审驳回 */
|
||||
async reject(
|
||||
id: bigint,
|
||||
reviewer: { id: bigint; name: string },
|
||||
dto: RejectSupportTicketDto,
|
||||
) {
|
||||
const ticket = await this.getOrThrow(id);
|
||||
if (ticket.status !== 'PENDING_REVIEW') {
|
||||
throw new BadRequestException('仅待评审工单可驳回');
|
||||
}
|
||||
const reason = dto.rejectReason.trim();
|
||||
if (!reason) throw new BadRequestException('请填写驳回理由');
|
||||
|
||||
const updated = await this.prisma.commonSupportTicket.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'REJECTED',
|
||||
rejectReason: reason,
|
||||
reviewerId: reviewer.id,
|
||||
reviewerName: reviewer.name,
|
||||
reviewedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
/** 开发完成 → 测试 */
|
||||
async startTesting(id: bigint, dto?: SupportTicketRemarkDto) {
|
||||
const ticket = await this.getOrThrow(id);
|
||||
if (ticket.status !== 'DEVELOPING') {
|
||||
throw new BadRequestException('仅开发中工单可转入测试');
|
||||
}
|
||||
const updated = await this.prisma.commonSupportTicket.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'TESTING',
|
||||
remark: dto?.remark?.trim() || ticket.remark,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
/** 测试通过 */
|
||||
async pass(id: bigint, dto?: SupportTicketRemarkDto) {
|
||||
const ticket = await this.getOrThrow(id);
|
||||
if (ticket.status !== 'TESTING') {
|
||||
throw new BadRequestException('仅测试中工单可标记通过');
|
||||
}
|
||||
const updated = await this.prisma.commonSupportTicket.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'PASSED',
|
||||
remark: dto?.remark?.trim() || ticket.remark,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateKnowledgeBaseRequest,
|
||||
CreateKnowledgeDocumentRequest,
|
||||
UpdateKnowledgeBaseRequest,
|
||||
} from '@dukang/shared-types';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
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 { AdminKnowledgeBasesService } from './admin-knowledge-bases.service';
|
||||
|
||||
@Controller('admin/knowledge-bases')
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('knowledge_bases')
|
||||
export class AdminKnowledgeBasesController {
|
||||
constructor(private readonly service: AdminKnowledgeBasesService) {}
|
||||
|
||||
@Get()
|
||||
async list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('name') name?: string,
|
||||
@Query('enabled') enabled?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.list(actor, {
|
||||
name,
|
||||
enabled,
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('options')
|
||||
async options(@CurrentUser() user: AuthUser) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.options(actor);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.detail(actor, BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.KNOWLEDGE_BASE_CREATE,
|
||||
refType: 'KNOWLEDGE_BASE',
|
||||
includeBody: true,
|
||||
})
|
||||
async create(@CurrentUser() user: AuthUser, @Body() body: CreateKnowledgeBaseRequest) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.create(actor, body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.KNOWLEDGE_BASE_UPDATE,
|
||||
refType: 'KNOWLEDGE_BASE',
|
||||
refIdField: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
async update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: UpdateKnowledgeBaseRequest,
|
||||
) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.update(actor, BigInt(id), body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.KNOWLEDGE_BASE_DELETE,
|
||||
refType: 'KNOWLEDGE_BASE',
|
||||
refIdField: 'id',
|
||||
})
|
||||
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.remove(actor, BigInt(id));
|
||||
}
|
||||
|
||||
@Get(':id/documents')
|
||||
async listDocuments(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.listDocuments(actor, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/documents')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.KNOWLEDGE_DOC_CREATE,
|
||||
refType: 'KNOWLEDGE_DOCUMENT',
|
||||
includeBody: true,
|
||||
})
|
||||
async addDocument(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: CreateKnowledgeDocumentRequest,
|
||||
) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.addDocument(actor, BigInt(id), body);
|
||||
}
|
||||
|
||||
@Delete(':id/documents/:docId')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.KNOWLEDGE_DOC_DELETE,
|
||||
refType: 'KNOWLEDGE_DOCUMENT',
|
||||
refIdField: 'docId',
|
||||
})
|
||||
async removeDocument(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Param('docId') docId: string,
|
||||
) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.removeDocument(actor, BigInt(id), BigInt(docId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateKnowledgeBaseRequest,
|
||||
CreateKnowledgeDocumentRequest,
|
||||
KnowledgeBaseDto,
|
||||
KnowledgeBaseOptionDto,
|
||||
KnowledgeDocumentDto,
|
||||
UpdateKnowledgeBaseRequest,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
type ActorCtx = { actorId: bigint; isSuperAdmin: boolean };
|
||||
|
||||
const TEXT_EXT = /\.(txt|md|markdown|csv|json|log)$/i;
|
||||
|
||||
@Injectable()
|
||||
export class AdminKnowledgeBasesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async resolveActor(actorId: bigint): Promise<ActorCtx> {
|
||||
const account = await this.prisma.hqAccount.findUnique({
|
||||
where: { id: actorId },
|
||||
select: { adminRole: true, status: true },
|
||||
});
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('账号不可用');
|
||||
}
|
||||
return {
|
||||
actorId,
|
||||
isSuperAdmin: account.adminRole === 'SUPER_ADMIN',
|
||||
};
|
||||
}
|
||||
|
||||
async list(
|
||||
actor: ActorCtx,
|
||||
query: { name?: string; enabled?: string; page?: number; pageSize?: number },
|
||||
) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: {
|
||||
name?: { contains: string };
|
||||
enabled?: boolean;
|
||||
createdByHqAccountId?: bigint;
|
||||
} = {};
|
||||
if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId;
|
||||
if (query.name?.trim()) where.name = { contains: query.name.trim() };
|
||||
if (query.enabled === 'true' || query.enabled === 'false') {
|
||||
where.enabled = query.enabled === 'true';
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.knowledgeBase.findMany({
|
||||
where,
|
||||
orderBy: [{ id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { _count: { select: { documents: true } } },
|
||||
}),
|
||||
this.prisma.knowledgeBase.count({ where }),
|
||||
]);
|
||||
|
||||
const ownerIds = [...new Set(items.map((i) => i.createdByHqAccountId))];
|
||||
const owners = ownerIds.length
|
||||
? await this.prisma.hqAccount.findMany({
|
||||
where: { id: { in: ownerIds } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: [];
|
||||
const ownerMap = new Map(owners.map((o) => [o.id.toString(), o.name]));
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((row) =>
|
||||
this.toKbDto(row, actor, ownerMap.get(row.createdByHqAccountId.toString()) ?? null, row._count.documents),
|
||||
),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async options(actor: ActorCtx): Promise<KnowledgeBaseOptionDto[]> {
|
||||
const where: { enabled: boolean; createdByHqAccountId?: bigint } = { enabled: true };
|
||||
if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId;
|
||||
const rows = await this.prisma.knowledgeBase.findMany({
|
||||
where,
|
||||
orderBy: [{ id: 'desc' }],
|
||||
include: { _count: { select: { documents: true } } },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
name: r.name,
|
||||
enabled: r.enabled,
|
||||
documentCount: r._count.documents,
|
||||
}));
|
||||
}
|
||||
|
||||
async detail(actor: ActorCtx, id: bigint) {
|
||||
const row = await this.requireKb(actor, id);
|
||||
const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } });
|
||||
const owner = await this.prisma.hqAccount.findUnique({
|
||||
where: { id: row.createdByHqAccountId },
|
||||
select: { name: true },
|
||||
});
|
||||
return this.toKbDto(row, actor, owner?.name ?? null, count);
|
||||
}
|
||||
|
||||
async create(actor: ActorCtx, dto: CreateKnowledgeBaseRequest) {
|
||||
const name = dto.name?.trim();
|
||||
if (!name) throw new BadRequestException('请填写名称');
|
||||
const row = await this.prisma.knowledgeBase.create({
|
||||
data: {
|
||||
name,
|
||||
description: dto.description?.trim() || null,
|
||||
enabled: dto.enabled !== false,
|
||||
createdByHqAccountId: actor.actorId,
|
||||
},
|
||||
});
|
||||
return this.toKbDto(row, actor, null, 0);
|
||||
}
|
||||
|
||||
async update(actor: ActorCtx, id: bigint, dto: UpdateKnowledgeBaseRequest) {
|
||||
const row = await this.requireKb(actor, id);
|
||||
this.requireWrite(actor, row);
|
||||
|
||||
if (!actor.isSuperAdmin) {
|
||||
// 创建人可改名称/描述/启用
|
||||
const data: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
} = {};
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('名称不能为空');
|
||||
data.name = name;
|
||||
}
|
||||
if (dto.description !== undefined) data.description = dto.description?.trim() || null;
|
||||
if (dto.enabled !== undefined) data.enabled = dto.enabled;
|
||||
const updated = await this.prisma.knowledgeBase.update({ where: { id }, data });
|
||||
const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } });
|
||||
return this.toKbDto(updated, actor, null, count);
|
||||
}
|
||||
|
||||
const data: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
} = {};
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('名称不能为空');
|
||||
data.name = name;
|
||||
}
|
||||
if (dto.description !== undefined) data.description = dto.description?.trim() || null;
|
||||
if (dto.enabled !== undefined) data.enabled = dto.enabled;
|
||||
const updated = await this.prisma.knowledgeBase.update({ where: { id }, data });
|
||||
const count = await this.prisma.knowledgeDocument.count({ where: { knowledgeBaseId: id } });
|
||||
return this.toKbDto(updated, actor, null, count);
|
||||
}
|
||||
|
||||
async remove(actor: ActorCtx, id: bigint) {
|
||||
const row = await this.requireKb(actor, id);
|
||||
this.requireWrite(actor, row);
|
||||
await this.prisma.knowledgeBase.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listDocuments(actor: ActorCtx, kbId: bigint) {
|
||||
await this.requireKb(actor, kbId);
|
||||
const rows = await this.prisma.knowledgeDocument.findMany({
|
||||
where: { knowledgeBaseId: kbId },
|
||||
orderBy: [{ id: 'desc' }],
|
||||
});
|
||||
return serializeBigInt({ items: rows.map((r) => this.toDocDto(r)) });
|
||||
}
|
||||
|
||||
async addDocument(actor: ActorCtx, kbId: bigint, dto: CreateKnowledgeDocumentRequest) {
|
||||
const kb = await this.requireKb(actor, kbId);
|
||||
this.requireWrite(actor, kb);
|
||||
|
||||
const title = dto.title?.trim();
|
||||
if (!title) throw new BadRequestException('请填写标题');
|
||||
|
||||
let contentText = dto.contentText?.trim() || '';
|
||||
let status: 'READY' | 'EMPTY' | 'FAILED' = 'EMPTY';
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
if (contentText) {
|
||||
status = 'READY';
|
||||
} else if (dto.fileUrl?.trim()) {
|
||||
const fileName = dto.fileName?.trim() || '';
|
||||
if (TEXT_EXT.test(fileName) || isLikelyTextMime(dto.mimeType)) {
|
||||
try {
|
||||
contentText = await fetchText(dto.fileUrl.trim());
|
||||
status = contentText.trim() ? 'READY' : 'EMPTY';
|
||||
if (!contentText.trim()) errorMessage = '文件内容为空';
|
||||
} catch (e) {
|
||||
status = 'FAILED';
|
||||
errorMessage = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
} else {
|
||||
status = 'EMPTY';
|
||||
errorMessage = '非文本文件未抽取正文,请粘贴文本或上传 .txt/.md';
|
||||
}
|
||||
} else {
|
||||
throw new BadRequestException('请粘贴正文或上传文件');
|
||||
}
|
||||
|
||||
const row = await this.prisma.knowledgeDocument.create({
|
||||
data: {
|
||||
knowledgeBaseId: kbId,
|
||||
title,
|
||||
fileName: dto.fileName?.trim() || null,
|
||||
fileUrl: dto.fileUrl?.trim() || null,
|
||||
mimeType: dto.mimeType?.trim() || null,
|
||||
sizeBytes: dto.sizeBytes ?? null,
|
||||
contentText: contentText || null,
|
||||
status,
|
||||
errorMessage,
|
||||
},
|
||||
});
|
||||
return this.toDocDto(row);
|
||||
}
|
||||
|
||||
async removeDocument(actor: ActorCtx, kbId: bigint, docId: bigint) {
|
||||
const kb = await this.requireKb(actor, kbId);
|
||||
this.requireWrite(actor, kb);
|
||||
const doc = await this.prisma.knowledgeDocument.findFirst({
|
||||
where: { id: docId, knowledgeBaseId: kbId },
|
||||
});
|
||||
if (!doc) throw new NotFoundException('文档不存在');
|
||||
await this.prisma.knowledgeDocument.delete({ where: { id: docId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async requireKb(actor: ActorCtx, id: bigint) {
|
||||
const row = await this.prisma.knowledgeBase.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('知识库不存在');
|
||||
if (!actor.isSuperAdmin && row.createdByHqAccountId !== actor.actorId) {
|
||||
throw new ForbiddenException('无权查看该知识库');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private requireWrite(
|
||||
actor: ActorCtx,
|
||||
row: { createdByHqAccountId: bigint },
|
||||
) {
|
||||
if (actor.isSuperAdmin) return;
|
||||
if (row.createdByHqAccountId !== actor.actorId) {
|
||||
throw new ForbiddenException('只能操作自己创建的知识库');
|
||||
}
|
||||
}
|
||||
|
||||
private toKbDto(
|
||||
row: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
createdByHqAccountId: bigint;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
},
|
||||
actor: ActorCtx,
|
||||
createdByName: string | null,
|
||||
documentCount: number,
|
||||
): KnowledgeBaseDto {
|
||||
const isOwner = row.createdByHqAccountId === actor.actorId;
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
enabled: row.enabled,
|
||||
documentCount,
|
||||
createdByHqAccountId: row.createdByHqAccountId.toString(),
|
||||
createdByName,
|
||||
isOwner,
|
||||
canEditFull: actor.isSuperAdmin || isOwner,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toDocDto(row: {
|
||||
id: bigint;
|
||||
knowledgeBaseId: bigint;
|
||||
title: string;
|
||||
fileName: string | null;
|
||||
fileUrl: string | null;
|
||||
mimeType: string | null;
|
||||
sizeBytes: number | null;
|
||||
contentText: string | null;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): KnowledgeDocumentDto {
|
||||
const status =
|
||||
row.status === 'READY' || row.status === 'FAILED' || row.status === 'EMPTY'
|
||||
? row.status
|
||||
: 'EMPTY';
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
knowledgeBaseId: row.knowledgeBaseId.toString(),
|
||||
title: row.title,
|
||||
fileName: row.fileName,
|
||||
fileUrl: row.fileUrl,
|
||||
mimeType: row.mimeType,
|
||||
sizeBytes: row.sizeBytes,
|
||||
hasContent: !!(row.contentText && row.contentText.trim()),
|
||||
status,
|
||||
errorMessage: row.errorMessage,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelyTextMime(mime?: string | null) {
|
||||
if (!mime) return false;
|
||||
return (
|
||||
mime.startsWith('text/') ||
|
||||
mime === 'application/json' ||
|
||||
mime === 'application/markdown'
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`下载文件失败 HTTP ${res.status}`);
|
||||
const buf = await res.arrayBuffer();
|
||||
if (buf.byteLength > 2 * 1024 * 1024) throw new Error('文本文件超过 2MB');
|
||||
return new TextDecoder('utf-8', { fatal: false }).decode(buf);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateLlmApiConfigRequest,
|
||||
UpdateLlmApiConfigRequest,
|
||||
} from '@dukang/shared-types';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
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 { AdminLlmConfigsService } from './admin-llm-configs.service';
|
||||
|
||||
@Controller('admin/llm-configs')
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('llm_configs')
|
||||
export class AdminLlmConfigsController {
|
||||
constructor(private readonly service: AdminLlmConfigsService) {}
|
||||
|
||||
@Get()
|
||||
async list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('name') name?: string,
|
||||
@Query('enabled') enabled?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.list(actor, {
|
||||
name,
|
||||
enabled,
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('options')
|
||||
async options(@CurrentUser() user: AuthUser) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.options(actor);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.detail(actor, BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.LLM_CONFIG_CREATE,
|
||||
refType: 'LLM_CONFIG',
|
||||
includeBody: true,
|
||||
})
|
||||
async create(@CurrentUser() user: AuthUser, @Body() body: CreateLlmApiConfigRequest) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.create(actor, body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.LLM_CONFIG_UPDATE,
|
||||
refType: 'LLM_CONFIG',
|
||||
refIdField: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
async update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: UpdateLlmApiConfigRequest,
|
||||
) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.update(actor, BigInt(id), body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.LLM_CONFIG_DELETE,
|
||||
refType: 'LLM_CONFIG',
|
||||
refIdField: 'id',
|
||||
})
|
||||
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.remove(actor, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/test')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.LLM_CONFIG_TEST,
|
||||
refType: 'LLM_CONFIG',
|
||||
refIdField: 'id',
|
||||
})
|
||||
async test(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
const actor = await this.service.resolveActor(user.actorId);
|
||||
return this.service.test(actor, BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
LLM_PROVIDERS,
|
||||
LLM_PROVIDER_PRESETS,
|
||||
type CreateLlmApiConfigRequest,
|
||||
type LlmApiConfigDto,
|
||||
type LlmApiConfigOptionDto,
|
||||
type LlmProvider,
|
||||
type UpdateLlmApiConfigRequest,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { LlmChatClient, normalizeLlmBaseUrl } from '../../integrations/llm/llm-chat.client';
|
||||
|
||||
type ActorCtx = { actorId: bigint; isSuperAdmin: boolean };
|
||||
|
||||
function isProvider(v: string): v is LlmProvider {
|
||||
return (LLM_PROVIDERS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminLlmConfigsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly llm: LlmChatClient,
|
||||
) {}
|
||||
|
||||
async resolveActor(actorId: bigint): Promise<ActorCtx> {
|
||||
const account = await this.prisma.hqAccount.findUnique({
|
||||
where: { id: actorId },
|
||||
select: { adminRole: true, status: true },
|
||||
});
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('账号不可用');
|
||||
}
|
||||
return {
|
||||
actorId,
|
||||
isSuperAdmin: account.adminRole === 'SUPER_ADMIN',
|
||||
};
|
||||
}
|
||||
|
||||
async list(
|
||||
actor: ActorCtx,
|
||||
query: { name?: string; enabled?: string; page?: number; pageSize?: number },
|
||||
) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: {
|
||||
name?: { contains: string };
|
||||
enabled?: boolean;
|
||||
createdByHqAccountId?: bigint;
|
||||
} = {};
|
||||
if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId;
|
||||
if (query.name?.trim()) where.name = { contains: query.name.trim() };
|
||||
if (query.enabled === 'true' || query.enabled === 'false') {
|
||||
where.enabled = query.enabled === 'true';
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.llmApiConfig.findMany({
|
||||
where,
|
||||
orderBy: [{ id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.llmApiConfig.count({ where }),
|
||||
]);
|
||||
|
||||
const ownerIds = [...new Set(items.map((i) => i.createdByHqAccountId))];
|
||||
const owners = ownerIds.length
|
||||
? await this.prisma.hqAccount.findMany({
|
||||
where: { id: { in: ownerIds } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: [];
|
||||
const ownerMap = new Map(owners.map((o) => [o.id.toString(), o.name]));
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((row) => this.toDto(row, actor, ownerMap.get(row.createdByHqAccountId.toString()) ?? null)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
/** 企微绑定下拉:已启用;非超管仅自己的 */
|
||||
async options(actor: ActorCtx): Promise<LlmApiConfigOptionDto[]> {
|
||||
const where: { enabled: boolean; createdByHqAccountId?: bigint } = { enabled: true };
|
||||
if (!actor.isSuperAdmin) where.createdByHqAccountId = actor.actorId;
|
||||
const rows = await this.prisma.llmApiConfig.findMany({
|
||||
where,
|
||||
orderBy: [{ id: 'desc' }],
|
||||
select: { id: true, name: true, provider: true, modelName: true, enabled: true },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
name: r.name,
|
||||
provider: (isProvider(r.provider) ? r.provider : 'CUSTOM') as LlmProvider,
|
||||
modelName: r.modelName,
|
||||
enabled: r.enabled,
|
||||
}));
|
||||
}
|
||||
|
||||
async detail(actor: ActorCtx, id: bigint) {
|
||||
const row = await this.requireReadable(actor, id);
|
||||
const owner = await this.prisma.hqAccount.findUnique({
|
||||
where: { id: row.createdByHqAccountId },
|
||||
select: { name: true },
|
||||
});
|
||||
return this.toDto(row, actor, owner?.name ?? null);
|
||||
}
|
||||
|
||||
async create(actor: ActorCtx, dto: CreateLlmApiConfigRequest) {
|
||||
const name = dto.name?.trim();
|
||||
if (!name) throw new BadRequestException('请填写名称');
|
||||
if (!isProvider(dto.provider)) throw new BadRequestException('无效提供商');
|
||||
const apiKey = dto.apiKey?.trim();
|
||||
if (!apiKey) throw new BadRequestException('请填写 API Key');
|
||||
|
||||
const preset = LLM_PROVIDER_PRESETS[dto.provider];
|
||||
const baseUrl = normalizeLlmBaseUrl(dto.baseUrl?.trim() || preset.defaultBaseUrl);
|
||||
const modelName = dto.modelName?.trim() || preset.defaultModel;
|
||||
if (!baseUrl) throw new BadRequestException('请填写 Base URL');
|
||||
if (!modelName) throw new BadRequestException('请填写模型名');
|
||||
|
||||
const row = await this.prisma.llmApiConfig.create({
|
||||
data: {
|
||||
name,
|
||||
provider: dto.provider,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
modelName,
|
||||
temperature: dto.temperature ?? null,
|
||||
maxTokens: dto.maxTokens ?? null,
|
||||
systemPrompt: dto.systemPrompt?.trim() || null,
|
||||
enabled: dto.enabled !== false,
|
||||
createdByHqAccountId: actor.actorId,
|
||||
},
|
||||
});
|
||||
return this.toDto(row, actor, null);
|
||||
}
|
||||
|
||||
async update(actor: ActorCtx, id: bigint, dto: UpdateLlmApiConfigRequest) {
|
||||
const row = await this.requireReadable(actor, id);
|
||||
const isOwner = row.createdByHqAccountId === actor.actorId;
|
||||
|
||||
if (!actor.isSuperAdmin) {
|
||||
if (!isOwner) throw new ForbiddenException('只能操作自己创建的配置');
|
||||
// 非超管仅可改 enabled
|
||||
const keys = Object.keys(dto).filter((k) => (dto as Record<string, unknown>)[k] !== undefined);
|
||||
if (keys.some((k) => k !== 'enabled')) {
|
||||
throw new ForbiddenException('非超级管理员只能修改配置是否生效');
|
||||
}
|
||||
if (dto.enabled === undefined) throw new BadRequestException('请指定 enabled');
|
||||
const updated = await this.prisma.llmApiConfig.update({
|
||||
where: { id },
|
||||
data: { enabled: dto.enabled },
|
||||
});
|
||||
return this.toDto(updated, actor, null);
|
||||
}
|
||||
|
||||
// 超管全量
|
||||
let provider = row.provider as LlmProvider;
|
||||
if (dto.provider !== undefined) {
|
||||
if (!isProvider(dto.provider)) throw new BadRequestException('无效提供商');
|
||||
provider = dto.provider;
|
||||
}
|
||||
const preset = LLM_PROVIDER_PRESETS[provider];
|
||||
const data: {
|
||||
name?: string;
|
||||
provider?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
modelName?: string;
|
||||
temperature?: number | null;
|
||||
maxTokens?: number | null;
|
||||
systemPrompt?: string | null;
|
||||
enabled?: boolean;
|
||||
} = {};
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('名称不能为空');
|
||||
data.name = name;
|
||||
}
|
||||
if (dto.provider !== undefined) data.provider = provider;
|
||||
if (dto.baseUrl !== undefined) {
|
||||
data.baseUrl = normalizeLlmBaseUrl(dto.baseUrl.trim() || preset.defaultBaseUrl);
|
||||
if (!data.baseUrl) throw new BadRequestException('Base URL 不能为空');
|
||||
}
|
||||
if (dto.apiKey !== undefined && dto.apiKey.trim()) data.apiKey = dto.apiKey.trim();
|
||||
if (dto.modelName !== undefined) {
|
||||
data.modelName = dto.modelName.trim() || preset.defaultModel;
|
||||
if (!data.modelName) throw new BadRequestException('模型名不能为空');
|
||||
}
|
||||
if (dto.temperature !== undefined) data.temperature = dto.temperature;
|
||||
if (dto.maxTokens !== undefined) data.maxTokens = dto.maxTokens;
|
||||
if (dto.systemPrompt !== undefined) data.systemPrompt = dto.systemPrompt?.trim() || null;
|
||||
if (dto.enabled !== undefined) data.enabled = dto.enabled;
|
||||
|
||||
const updated = await this.prisma.llmApiConfig.update({ where: { id }, data });
|
||||
return this.toDto(updated, actor, null);
|
||||
}
|
||||
|
||||
async remove(actor: ActorCtx, id: bigint) {
|
||||
if (!actor.isSuperAdmin) {
|
||||
throw new ForbiddenException('仅超级管理员可删除语言模型配置');
|
||||
}
|
||||
const row = await this.prisma.llmApiConfig.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('配置不存在');
|
||||
await this.prisma.llmApiConfig.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async test(actor: ActorCtx, id: bigint) {
|
||||
const row = await this.requireReadable(actor, id);
|
||||
if (!row.enabled) throw new BadRequestException('配置未启用');
|
||||
const reply = await this.llm.chat({
|
||||
baseUrl: row.baseUrl,
|
||||
apiKey: row.apiKey,
|
||||
model: row.modelName,
|
||||
temperature: row.temperature != null ? Number(row.temperature) : 0.2,
|
||||
maxTokens: row.maxTokens ?? 64,
|
||||
messages: [
|
||||
{ role: 'system', content: '用一句话回复:连接成功。' },
|
||||
{ role: 'user', content: 'ping' },
|
||||
],
|
||||
});
|
||||
return { ok: true, reply };
|
||||
}
|
||||
|
||||
private async requireReadable(actor: ActorCtx, id: bigint) {
|
||||
const row = await this.prisma.llmApiConfig.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('配置不存在');
|
||||
if (!actor.isSuperAdmin && row.createdByHqAccountId !== actor.actorId) {
|
||||
throw new ForbiddenException('无权查看该配置');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private toDto(
|
||||
row: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
provider: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
modelName: string;
|
||||
temperature: { toNumber?: () => number } | number | null;
|
||||
maxTokens: number | null;
|
||||
systemPrompt: string | null;
|
||||
enabled: boolean;
|
||||
createdByHqAccountId: bigint;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
},
|
||||
actor: ActorCtx,
|
||||
createdByName: string | null,
|
||||
): LlmApiConfigDto {
|
||||
const isOwner = row.createdByHqAccountId === actor.actorId;
|
||||
const temp =
|
||||
row.temperature == null
|
||||
? null
|
||||
: typeof row.temperature === 'number'
|
||||
? row.temperature
|
||||
: Number(row.temperature);
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
provider: (isProvider(row.provider) ? row.provider : 'CUSTOM') as LlmProvider,
|
||||
baseUrl: row.baseUrl,
|
||||
modelName: row.modelName,
|
||||
apiKeyConfigured: !!row.apiKey,
|
||||
temperature: temp,
|
||||
maxTokens: row.maxTokens,
|
||||
systemPrompt: row.systemPrompt,
|
||||
enabled: row.enabled,
|
||||
createdByHqAccountId: row.createdByHqAccountId.toString(),
|
||||
createdByName,
|
||||
isOwner,
|
||||
canEditFull: actor.isSuperAdmin,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
import { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { SupportTicketService } from '../common/support-ticket.service';
|
||||
import {
|
||||
CreateSupportTicketDto,
|
||||
RejectSupportTicketDto,
|
||||
SupportTicketListQueryDto,
|
||||
SupportTicketRemarkDto,
|
||||
} from '../common/dto/support-ticket.dto';
|
||||
|
||||
@Controller('admin/support-tickets')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminSupportTicketsController {
|
||||
constructor(
|
||||
private readonly service: SupportTicketService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
private async resolveHqAccount(user: AuthUser) {
|
||||
const account = await this.prisma.hqAccount.findUnique({
|
||||
where: { id: user.actorId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!account) throw new NotFoundException('HQ 账户不存在');
|
||||
return account;
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: SupportTicketListQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SUPPORT_TICKET_CREATE,
|
||||
refType: 'SUPPORT_TICKET',
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
async create(@CurrentUser() user: AuthUser, @Body() body: CreateSupportTicketDto) {
|
||||
const account = await this.resolveHqAccount(user);
|
||||
return this.service.create(body, account);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SUPPORT_TICKET_APPROVE,
|
||||
refType: 'SUPPORT_TICKET',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
async approve(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: SupportTicketRemarkDto,
|
||||
) {
|
||||
const account = await this.resolveHqAccount(user);
|
||||
return this.service.approve(BigInt(id), account, body);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SUPPORT_TICKET_REJECT,
|
||||
refType: 'SUPPORT_TICKET',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
async reject(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: RejectSupportTicketDto,
|
||||
) {
|
||||
const account = await this.resolveHqAccount(user);
|
||||
return this.service.reject(BigInt(id), account, body);
|
||||
}
|
||||
|
||||
@Post(':id/start-testing')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SUPPORT_TICKET_START_TESTING,
|
||||
refType: 'SUPPORT_TICKET',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
startTesting(@Param('id') id: string, @Body() body: SupportTicketRemarkDto) {
|
||||
return this.service.startTesting(BigInt(id), body);
|
||||
}
|
||||
|
||||
@Post(':id/pass')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SUPPORT_TICKET_PASS,
|
||||
refType: 'SUPPORT_TICKET',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
pass(@Param('id') id: string, @Body() body: SupportTicketRemarkDto) {
|
||||
return this.service.pass(BigInt(id), body);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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 { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { AdminWecomBotsService } from './admin-wecom-bots.service';
|
||||
import { AdminLlmConfigsService } from './admin-llm-configs.service';
|
||||
import { AdminKnowledgeBasesService } from './admin-knowledge-bases.service';
|
||||
|
||||
@Controller('admin/wecom-bots')
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('wecom_bots')
|
||||
export class AdminWecomBotsController {
|
||||
constructor(
|
||||
private readonly service: AdminWecomBotsService,
|
||||
private readonly llmConfigs: AdminLlmConfigsService,
|
||||
private readonly knowledgeBases: AdminKnowledgeBasesService,
|
||||
) {}
|
||||
|
||||
@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('ai-options')
|
||||
async aiOptions(@CurrentUser() user: AuthUser) {
|
||||
const actor = await this.llmConfigs.resolveActor(user.actorId);
|
||||
const [llmConfigs, knowledgeBases] = await Promise.all([
|
||||
this.llmConfigs.options(actor),
|
||||
this.knowledgeBases.options(actor),
|
||||
]);
|
||||
return { llmConfigs, knowledgeBases };
|
||||
}
|
||||
|
||||
@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,258 @@
|
||||
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);
|
||||
}
|
||||
|
||||
type WecomRow = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
role: string;
|
||||
botId: string;
|
||||
secret: string;
|
||||
avatarUrl: string | null;
|
||||
welcome: string | null;
|
||||
permissions: string;
|
||||
aiEnabled: boolean;
|
||||
llmConfigId: bigint | null;
|
||||
knowledgeBaseId: bigint | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
llmConfig?: { id: bigint; name: string } | null;
|
||||
knowledgeBase?: { id: bigint; name: string } | null;
|
||||
};
|
||||
|
||||
@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,
|
||||
include: {
|
||||
llmConfig: { select: { id: true, name: true } },
|
||||
knowledgeBase: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
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 },
|
||||
include: {
|
||||
llmConfig: { select: { id: true, name: true } },
|
||||
knowledgeBase: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
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 llmConfigId = await this.resolveLlmId(dto.llmConfigId);
|
||||
const knowledgeBaseId = await this.resolveKbId(dto.knowledgeBaseId);
|
||||
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),
|
||||
aiEnabled: dto.aiEnabled === true,
|
||||
llmConfigId,
|
||||
knowledgeBaseId,
|
||||
enabled: dto.enabled !== false,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
include: {
|
||||
llmConfig: { select: { id: true, name: true } },
|
||||
knowledgeBase: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
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 llmConfigId =
|
||||
dto.llmConfigId !== undefined ? await this.resolveLlmId(dto.llmConfigId) : undefined;
|
||||
const knowledgeBaseId =
|
||||
dto.knowledgeBaseId !== undefined ? await this.resolveKbId(dto.knowledgeBaseId) : undefined;
|
||||
|
||||
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,
|
||||
aiEnabled: dto.aiEnabled,
|
||||
llmConfigId,
|
||||
knowledgeBaseId,
|
||||
enabled: dto.enabled,
|
||||
sortOrder: dto.sortOrder,
|
||||
},
|
||||
include: {
|
||||
llmConfig: { select: { id: true, name: true } },
|
||||
knowledgeBase: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
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 async resolveLlmId(raw?: string | null): Promise<bigint | null> {
|
||||
if (raw === undefined) return null;
|
||||
if (raw === null || raw === '') return null;
|
||||
const id = BigInt(raw);
|
||||
const row = await this.prisma.llmApiConfig.findUnique({ where: { id } });
|
||||
if (!row) throw new BadRequestException('语言模型配置不存在');
|
||||
return id;
|
||||
}
|
||||
|
||||
private async resolveKbId(raw?: string | null): Promise<bigint | null> {
|
||||
if (raw === undefined) return null;
|
||||
if (raw === null || raw === '') return null;
|
||||
const id = BigInt(raw);
|
||||
const row = await this.prisma.knowledgeBase.findUnique({ where: { id } });
|
||||
if (!row) throw new BadRequestException('知识库不存在');
|
||||
return id;
|
||||
}
|
||||
|
||||
private toDto(row: WecomRow): 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,
|
||||
aiEnabled: row.aiEnabled,
|
||||
llmConfigId: row.llmConfigId?.toString() ?? null,
|
||||
llmConfigName: row.llmConfig?.name ?? null,
|
||||
knowledgeBaseId: row.knowledgeBaseId?.toString() ?? null,
|
||||
knowledgeBaseName: row.knowledgeBase?.name ?? null,
|
||||
enabled: row.enabled,
|
||||
sortOrder: row.sortOrder,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,14 @@ import { AdminOssLogsController } from './admin-oss-logs.controller';
|
||||
import { AdminOssLogsService } from './admin-oss-logs.service';
|
||||
import { AdminTicketsController, PartnerTicketsController } from './admin-tickets.controller';
|
||||
import { AdminTicketsService } from './admin-tickets.service';
|
||||
import { AdminSupportTicketsController } from './admin-support-tickets.controller';
|
||||
import { AdminInvoicesController } from './admin-invoices.controller';
|
||||
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 { LlmModule } from '../../integrations/llm/llm.module';
|
||||
import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller';
|
||||
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
||||
import { AdminProductDetailTemplatesController } from './admin-product-detail-templates.controller';
|
||||
@@ -58,10 +61,16 @@ 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 { AdminLlmConfigsController } from './admin-llm-configs.controller';
|
||||
import { AdminLlmConfigsService } from './admin-llm-configs.service';
|
||||
import { AdminKnowledgeBasesController } from './admin-knowledge-bases.controller';
|
||||
import { AdminKnowledgeBasesService } from './admin-knowledge-bases.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, LlmModule, RedeemModule, StoreModule],
|
||||
controllers: [
|
||||
AdminDashboardController,
|
||||
AdminDeployController,
|
||||
@@ -88,6 +97,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
||||
AdminHqLogsController,
|
||||
AdminOssLogsController,
|
||||
AdminTicketsController,
|
||||
AdminSupportTicketsController,
|
||||
AdminInvoicesController,
|
||||
AdminXiaofeixiaController,
|
||||
AdminProductDetailTemplatesController,
|
||||
@@ -96,6 +106,9 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
||||
AdminWechatBindingsController,
|
||||
AdminHqPermissionsController,
|
||||
AdminSystemConfigController,
|
||||
AdminWecomBotsController,
|
||||
AdminLlmConfigsController,
|
||||
AdminKnowledgeBasesController,
|
||||
AdminFulfillmentProvidersController,
|
||||
],
|
||||
providers: [
|
||||
@@ -122,6 +135,9 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
||||
AdminWechatBindingsService,
|
||||
AdminHqPermissionsService,
|
||||
AdminDeployService,
|
||||
AdminWecomBotsService,
|
||||
AdminLlmConfigsService,
|
||||
AdminKnowledgeBasesService,
|
||||
SuperAdminGuard,
|
||||
],
|
||||
exports: [CityScopeModule],
|
||||
|
||||
+906
@@ -0,0 +1,906 @@
|
||||
# 杜康好客 · 业务知识库
|
||||
|
||||
> **版本**:基于 V3.0 PRD 与当前四端实现整理
|
||||
> **事实源**:[`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)
|
||||
> **实现验收**:[`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md)
|
||||
> **用途**:新人上手、运营培训、客服/财务/开发协作说明(非需求变更文档)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [整体概述](#1-整体概述)
|
||||
2. [用户小程序端](#2-用户小程序端)
|
||||
3. [门店端](#3-门店端)
|
||||
4. [合伙人端](#4-合伙人端)
|
||||
5. [HQ 总部后台](#5-hq-总部后台)
|
||||
6. [商品上传与详情模板](#6-商品上传与详情模板)
|
||||
7. [活动的创建](#7-活动的创建)
|
||||
8. [开城流程](#8-开城流程)
|
||||
9. [开店流程](#9-开店流程)
|
||||
10. [订单处理](#10-订单处理)
|
||||
11. [财务模块](#11-财务模块)
|
||||
12. [发票模块](#12-发票模块)
|
||||
13. [工单模块](#13-工单模块)
|
||||
14. [配送单](#14-配送单)
|
||||
15. [好客权益](#15-好客权益)
|
||||
16. [系统设置](#16-系统设置)
|
||||
17. [企业微信对接](#17-企业微信对接)
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体概述
|
||||
|
||||
### 1.1 产品定位
|
||||
|
||||
**杜康好客**是杜康酒业 O2O 平台,核心链路为:
|
||||
|
||||
**购酒 → 发放等额好客权益 → 合作餐饮门店核销(酒 + 餐)**
|
||||
|
||||
商业模型:
|
||||
|
||||
| 环节 | 说明 |
|
||||
|------|------|
|
||||
| 总部 | 供酒、开城、商品、审核、结算打款、售后 |
|
||||
| 城市合伙人 | 分销拓店、辖区经营、佣金对账 |
|
||||
| 门店 | 核销好客权益,按核销额 60% 结算 |
|
||||
| C 端用户 | 买酒得 1:1 权益,到店核销消费 |
|
||||
|
||||
**成本与分润锚点(不可偏离)**:
|
||||
|
||||
- 酒水成本约售价 **3 折**
|
||||
- 门店结算 = 核销金额 × **60%**
|
||||
- 单合伙人「订单佣金 + 核销佣金」≤ 订单金额 **5%**(默认订单 0% + 核销 3%)
|
||||
- 权益:支付成功赠送订单实付 **1:1**,永久有效
|
||||
- 权益额口径:`benefit_amount ?? price`
|
||||
- 核销码 Redis TTL:**3 分钟**
|
||||
|
||||
### 1.2 四端职责一览
|
||||
|
||||
| 端 | 形态 | 主要职责 |
|
||||
|----|------|----------|
|
||||
| **用户端** | 微信小程序(联调可用 H5 `h5-user`) | 浏览购酒、支付、履约/提货、权益出码、找店核销、售后/发票、客服 |
|
||||
| **门店端** | H5(`h5-shop`) | 扫码/手机号核销、核销记录、营业状态、子账号、结算提现 |
|
||||
| **合伙人端** | H5(`h5-partner`) | 拓店三步录入、辖区订单/数据、负责人复核、月账对账、代下单 |
|
||||
| **总部 HQ** | WebAdmin(`admin-web`) | 开城/商品/门店审核、订单履约、财务打款、发票/工单、权益、系统配置 |
|
||||
|
||||
签约主体:**山西领势酒业有限责任公司**。
|
||||
|
||||
试点城市:**郑州**。配送:同城小飞侠;跨城总部物流到付。
|
||||
|
||||
### 1.3 五条业务主闭环
|
||||
|
||||
```
|
||||
购酒履约 ──► 已完成 + 权益 1:1
|
||||
权益核销 ──► 扣减权益 + 门店账本×60% + 核销佣金
|
||||
拓店入驻 ──► 审核 + 试核销 ──► 营业中 C 端可见
|
||||
售后工单 ──► 总部审 + 仓/合伙人协同 ──► 补发/退款
|
||||
结算提现 ──► T+1 出账 / 未出账可提 ──► 总部审后打款
|
||||
```
|
||||
|
||||
### 1.4 技术骨架(便于协作)
|
||||
|
||||
- API:`/api/v1`,响应 `{ code, message, data }`
|
||||
- 单体 NestJS:`server/dukang-api`
|
||||
- 共享契约:`packages/shared-types`;纯规则:`packages/domain`
|
||||
- 统一 UI 主色:杜康红 `#8B1E1E`~`#A12828`;权益金 `#C4A35A`
|
||||
|
||||
---
|
||||
|
||||
## 2. 用户小程序端
|
||||
|
||||
> PRD 交付形态为 **微信小程序**;仓库中 `apps/mini-user` 为小程序端,`apps/h5-user` 为 H5 联调过渡。
|
||||
|
||||
### 2.1 导航与账号
|
||||
|
||||
- 四 Tab:首页 / 订单(或权益相关入口)/ 门店 / 我的(以实际小程序 Tab 为准)
|
||||
- 无感登录 + 7 天免登;确认下单可提示绑定手机号(可选、不强制)
|
||||
- **仅微信支付**;待付款锁单 **30 分钟**未付自动取消
|
||||
|
||||
### 2.2 购酒与履约
|
||||
|
||||
| 场景 | 规则摘要 |
|
||||
|------|----------|
|
||||
| **同城** | 已开城城市;起购 **≥2 瓶**;免运费;小飞侠/仓配履约;送达后确认或 24h 自动完成 |
|
||||
| **跨城** | 未开通城市;起购 **≥1 箱(6 瓶)**;总部物流 **到付**;订单佣金归总部 |
|
||||
| **现场提货** | 隐藏入口(推广码场景);支付后直接 **已完成** 并发权益 |
|
||||
|
||||
订单列表 Tab(V3):**待付款 | 已付款 | 已完成**。
|
||||
|
||||
### 2.3 好客权益与核销
|
||||
|
||||
- 支付成功发放实付金额 **1:1** 权益,永久有效
|
||||
- 「我的 / 好客权益」查看余额与明细
|
||||
- 出码核销:码有效期 **3 分钟**;核销前展示规则弹窗(附件一)
|
||||
- 也可到店报手机号由门店发起核销
|
||||
|
||||
### 2.4 门店发现
|
||||
|
||||
- 门店列表 / 详情 / 搜索 / 省市区筛选
|
||||
- C 端仅展示 **营业中(OPEN)** 门店
|
||||
- 支持「立即核销」跳转出码
|
||||
|
||||
### 2.5 售后、发票与增长
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| 售后工单 | 四类型:仅退款 / 破损补发 / 破损退货 / 退货退款 |
|
||||
| 发票 | 个人/企业 × 普票/专票 组合申请 |
|
||||
| 客服 | 电话客服 + 企业微信在线客服(见 §17) |
|
||||
| 推广码 | 扫码进小程序归因合伙人/渠道 |
|
||||
| 问卷与评价 | 成交后问卷(无权益激励);核销后门店评价 |
|
||||
|
||||
### 2.6 SKU 价格锚点(酒祖杜康)
|
||||
|
||||
| 商品 | 瓶价 | 箱价(6瓶) |
|
||||
|------|------|-----------|
|
||||
| 国标特级 10 · 53° | ¥128 | ¥768 |
|
||||
| 国标特级 15 · 53° | ¥168 | ¥1008 |
|
||||
| 国标特级 20 · 53° | ¥298 | ¥1788 |
|
||||
| 国标特级 30 · 53° | ¥498 | ¥2988 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 门店端
|
||||
|
||||
> App:`apps/h5-shop`,微信内置浏览器 H5。
|
||||
|
||||
### 3.1 账号模型
|
||||
|
||||
| 角色 | 权限 |
|
||||
|------|------|
|
||||
| **主账号** | 核销、子账号管理、营业状态、提现、本店全量记录 |
|
||||
| **店员子账号** | 仅核销 + 本店核销记录 |
|
||||
| **一号多店** | 同一手机号可作多家店主账号;登录时选店;7 天免登记住上次门店 |
|
||||
|
||||
主账号来源:入驻第三步「负责人手机号」,每店仅一个主账号。
|
||||
|
||||
### 3.2 核销操作
|
||||
|
||||
1. 首页大按钮进入核销页
|
||||
2. **扫码通道**:扫描用户核销码
|
||||
3. **手机号通道**:输入用户手机号 + 核销金额 → 发短信验证码 → 验证成功后直接核销(同页完成)
|
||||
4. 核销成功:权益扣减、门店账本记入 **核销额 × 60%**、短信通知用户
|
||||
5. 今日汇总可在首页查看
|
||||
|
||||
**弱网兜底**:网络类失败重试;连续失败可拍照提交总部人工补核销(业务错误如码过期、余额不足不计入失败次数)。
|
||||
|
||||
### 3.3 记录、结算与提现
|
||||
|
||||
- 核销记录筛选:今日 / 7 日 / 1 月 / 全部
|
||||
- 到账金额展示按 ×60%
|
||||
- T+1 自然日出账(法定节假日顺延)
|
||||
- **未出账金额可申请提现**(受 FIN 护栏:白名单、单日上限等)
|
||||
- 结算异议:核销后 **3 个工作日**内附凭证提出
|
||||
|
||||
### 3.4 营业状态
|
||||
|
||||
主账号可切换营业状态;仅营业中门店对 C 端可见。
|
||||
|
||||
---
|
||||
|
||||
## 4. 合伙人端
|
||||
|
||||
> App:`apps/h5-partner`。
|
||||
|
||||
### 4.1 角色与权限
|
||||
|
||||
| 角色 | 菜单与能力 |
|
||||
|------|------------|
|
||||
| **管理员** | 拓店、经营看板、订单/权益/工单、财务对账、子账号、代下单等 |
|
||||
| **推广员** | 仅门店入驻 + 查看自己提交的店(须在管辖范围内) |
|
||||
|
||||
数据 **平级隔离**:全城/区域合伙人不可互查对方数据。手机号中间 4 位脱敏。
|
||||
|
||||
### 4.2 管辖类型
|
||||
|
||||
| 类型 | 范围 | 限制 |
|
||||
|------|------|------|
|
||||
| 全城合伙人 | 该城未被区域占用的区县 | 每城最多 1 名 |
|
||||
| 区域合伙人 | 总部勾选的区县 | 每城可多名;同一区县不可重复 |
|
||||
|
||||
### 4.3 拓店
|
||||
|
||||
三步录入 → 负责人复核 → 总部审核 → 试核销 100 元 → 正式入驻(详见 [§9 开店流程](#9-开店流程))。
|
||||
|
||||
### 4.4 经营与财务
|
||||
|
||||
- 首页三卡、排行、订单/权益/工单列表
|
||||
- 佣金快照可下钻(支付时落库,比例变更仅影响新单/新核销)
|
||||
- 本合伙人 **T+30 月账单**:独立确认、独立打款(无上下级二次分账)
|
||||
- Wave 3:代下单、管仓只读协同
|
||||
|
||||
### 4.5 佣金归属(摘要)
|
||||
|
||||
- **同城订单佣金**:收货区县 → 区域合伙人 → 否则全城合伙人 → 再无归总部
|
||||
- **跨城订单佣金**:归总部
|
||||
- **核销佣金**:归核销门店所属合伙人
|
||||
- **推广码**:主要用于归因统计;现场提货「有码归码」例外
|
||||
|
||||
---
|
||||
|
||||
## 5. HQ 总部后台
|
||||
|
||||
> App:`apps/admin-web`。登录:账号密码或短信验证码。菜单按 **角色权限** 裁剪。
|
||||
|
||||
### 5.1 角色总览
|
||||
|
||||
系统内置角色(`HQ_ADMIN_ROLES`):
|
||||
|
||||
| 角色 | 代码 | 定位 |
|
||||
|------|------|------|
|
||||
| **超级管理员** | `SUPER_ADMIN` | 全权限;权限分配;技术支持工单评审 |
|
||||
| **运营** | `OPS` | 商品/开城/门店/订单/配送/推广/权益日常运营 |
|
||||
| **财务** | `FINANCE` | 账单打款、发票、酒厂账户、订单与结算核对 |
|
||||
| **客服** | `CUSTOMER_SERVICE` | 用户/订单查询、售后工单、发票协助 |
|
||||
|
||||
> 说明:系统 **无独立「开发」角色码**。开发相关工作通过 **技术支持工单** 状态机完成:各角色可提单 → 超管评审 → 进入开发/测试/通过。下文「开发」指该协作职能。
|
||||
|
||||
权限可按角色默认值配置,也可按账号叠加个性化权限(`权限分配` 页,仅超管)。
|
||||
|
||||
### 5.2 运营(OPS)
|
||||
|
||||
**默认可见模块(摘要)**:概览、用户、微信绑定、商品(含详情模板)、订单、推广码、门店、开城(城市/合伙人/仓库/仓配)、好客权益、配送单、工单中心、技术支持、发票、OSS、日志、小程序展示配置等。
|
||||
|
||||
**日常工作**:
|
||||
|
||||
| 事项 | 菜单入口 | 要点 |
|
||||
|------|----------|------|
|
||||
| 开城与仓配 | 开城 → 城市 / 合伙人 / 仓库 / 仓配管理 | 见 §8 |
|
||||
| 商品上架 | 商品 → 商品列表 / 详情模板 | 见 §6 |
|
||||
| 门店审核 | 门店 → 门店列表 | 通过/驳回;试核销协同 |
|
||||
| 订单履约 | 订单 | 同城推单、跨城/无仓填单、现场提货单查看 |
|
||||
| 推广与活动 | 推广码 | 见 §7 |
|
||||
| 权益运维 | 好客权益 | 券/流水/核销记录/待处理核销 |
|
||||
| 配送跟踪 | 配送单 | 运单号维护、小飞侠联调 |
|
||||
| 内容资源 | OSS 资源库 | 图片等素材 |
|
||||
|
||||
### 5.3 财务(FINANCE)
|
||||
|
||||
**默认可见模块(摘要)**:概览、订单、门店、开城、**财务三账单**、好客权益、技术支持、发票、日志、酒厂银行账户设置。
|
||||
|
||||
**日常工作**:
|
||||
|
||||
| 事项 | 菜单入口 | 要点 |
|
||||
|------|----------|------|
|
||||
| 门店结算打款 | 财务 → 门店账单 | T+1 账期;确认打款 / 批量打款;导出 |
|
||||
| 合伙人佣金 | 财务 → 合伙人账单 | 月账确认与打款 |
|
||||
| 酒厂往来 | 财务 → 酒厂账单 | 与酒厂账户对照 |
|
||||
| 发票开具 | 发票管理 | 2 个工作日 SLA;回传 PDF/图片 |
|
||||
| 收款账户 | 系统设置 → 酒厂银行账户 | 户名/开户行/账号 |
|
||||
|
||||
### 5.4 客服(CUSTOMER_SERVICE)
|
||||
|
||||
**默认可见模块(摘要)**:概览、用户、订单、工单中心、技术支持、发票、日志。
|
||||
|
||||
**日常工作**:
|
||||
|
||||
| 事项 | 说明 |
|
||||
|------|------|
|
||||
| 查用户/订单 | 协助定位支付、履约、权益问题 |
|
||||
| 售后工单 | 受理用户四类型工单;同意/驳回;协同仓与合伙人 |
|
||||
| 发票协助 | 代查申请状态、催办开票 |
|
||||
| 弱网补核销 | 处理门店提交的待处理核销单(与运营/权益模块协同) |
|
||||
| 技术支持提单 | 将系统缺陷/建议提给超管评审 |
|
||||
|
||||
### 5.5 开发(技术支持工单协作)
|
||||
|
||||
入口:**工单 → 技术支持**(权限键 `tech_support`)。
|
||||
|
||||
| 步骤 | 操作人 | 状态 |
|
||||
|------|--------|------|
|
||||
| 创建 | 运营/财务/客服/超管等有权限账号 | `待评审` |
|
||||
| 评审通过 / 驳回 | **超级管理员** | 通过 → `开发`;驳回 → `已驳回`(需填写原因) |
|
||||
| 开发完成 | 有权限账号(通常超管/指定开发协作者) | `开发` → `测试` |
|
||||
| 测试通过 | 有权限账号 | `测试` → `通过` |
|
||||
|
||||
工单类型:`BUG` / `建议` / `其他`。
|
||||
用途:产品缺陷、体验建议、技术支持请求的闭环留痕,与 **售后工单中心**(用户订单售后)相互独立。
|
||||
|
||||
### 5.6 超级管理员
|
||||
|
||||
- 全部业务菜单
|
||||
- **权限分配**:按角色 / 按账号配置权限目录
|
||||
- **HQ 账户**:创建与维护总部账号
|
||||
- **技术支持工单评审**
|
||||
- **系统设置** 全部分组(功能开关、短信、微信、OSS、部署等)
|
||||
- **企微机器人**(与系统设置并列;总开关仍在功能开关)
|
||||
|
||||
### 5.7 HQ 菜单地图(速查)
|
||||
|
||||
| 分组 | 页面 |
|
||||
|------|------|
|
||||
| 概览 | Dashboard |
|
||||
| 用户 / 微信绑定 | 用户列表;OpenID/UnionID 多端身份 |
|
||||
| 商品 | 商品列表;详情模板 |
|
||||
| 订单 / 推广码 | 全量订单;推广码与归因用户 |
|
||||
| 门店 | 列表、分类、账户、资源 |
|
||||
| 开城 | 城市、城市合伙人、仓库、仓配管理 |
|
||||
| 财务 | 门店账单、合伙人账单、酒厂账单 |
|
||||
| 好客权益 | 权益券、流水、核销记录、待处理核销、核销调试 |
|
||||
| 配送单 | 列表;小飞侠联调 |
|
||||
| 工单 | 工单中心;技术支持 |
|
||||
| 发票 | 发票管理 |
|
||||
| 资源 / 日志 | OSS;用户/商户/合伙人/HQ/第三方日志 |
|
||||
| 企微机器人 | 多实例 Bot 配置(长连接指令助手,可绑语言模型与知识库,见 §17) |
|
||||
| 语言模型 | DeepSeek / OpenAI / 通义 / 自定义 API;非超管仅见自己的配置且只能改是否生效 |
|
||||
| 知识库 | 上传/粘贴文档,供企微机器人 AI 检索 |
|
||||
| 管理 | 权限分配;系统设置;HQ 账户 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 商品上传与详情模板
|
||||
|
||||
### 6.1 商品列表(运营)
|
||||
|
||||
路径:**商品 → 商品列表**。
|
||||
|
||||
创建/编辑字段要点:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| SKU / 69 码 | 商品编码与条码 |
|
||||
| 名称 / 副标题 / 香型 / 规格 | 展示信息 |
|
||||
| 售价 `price` | 用户支付价 |
|
||||
| 权益额 `benefitAmount` | 默认可与售价一致;规则为 `benefit_amount ?? price` |
|
||||
| 主图 / 轮播图 / 详情长图 | OSS 上传 |
|
||||
| 故事标题与正文、卖点特色 | 详情页文案结构 |
|
||||
| 是否允许现场提货 | `allowOnSitePickup` |
|
||||
| 状态 / 排序 | 上架与列表顺序 |
|
||||
|
||||
营销规则:支付成功按实付 **1:1** 发放好客权益(总部「营销规则」口径,与商品权益额配合)。
|
||||
|
||||
### 6.2 详情模板
|
||||
|
||||
路径:**商品 → 详情模板**。
|
||||
|
||||
用途:把「故事 + 卖点 + 详情长图」沉淀为可复用模板,新建/编辑商品时 **套用模板**,再按 SKU 微调图片与文案。
|
||||
|
||||
模板字段:
|
||||
|
||||
- 模板编码 `code`(唯一)、名称、描述、香型
|
||||
- 详情长图列表、建议张数
|
||||
- 故事标题/正文
|
||||
- 卖点列表(图标名、标题、描述)
|
||||
- 排序、状态(启用/停用)
|
||||
|
||||
**推荐操作流**:
|
||||
|
||||
1. 先在「详情模板」建好品牌统一长图与话术
|
||||
2. 商品表单中选择模板一键填充
|
||||
3. 替换该 SKU 专属主图/轮播后上架
|
||||
|
||||
---
|
||||
|
||||
## 7. 活动的创建
|
||||
|
||||
本期「活动」主要落在 **推广码** 与 **现场提货/品鉴** 场景,而非独立营销中台。
|
||||
|
||||
### 7.1 推广码创建
|
||||
|
||||
路径:**推广码**。
|
||||
|
||||
| 场景 `scene` | 用途 |
|
||||
|--------------|------|
|
||||
| 线上链接 | H5/小程序落地链接归因 |
|
||||
| 现场提货 | 线下提货隐藏入口;有码则订单佣金归码所属方 |
|
||||
| 合伙人渠道 | 合伙人拓客统计 |
|
||||
| **活动品鉴** | 品鉴会等活动场次 |
|
||||
| 其他 | 自定义 |
|
||||
|
||||
创建时填写:名称、场景、备注、归属用户(可选)等;系统生成码值、落地 URL、二维码。可查看扫码次数、订单数、转化率、归因用户列表。
|
||||
|
||||
### 7.2 活动执行要点
|
||||
|
||||
1. HQ 创建「活动品鉴」或「现场提货」推广码并下载二维码
|
||||
2. 现场物料投放;用户扫码进入小程序完成绑定归因
|
||||
3. 现场提货订单支付后直接完成并发权益
|
||||
4. 后台在推广码详情核对扫码/成交数据
|
||||
|
||||
权益发放规则活动期仍遵循 **1:1 实付**,不额外叠加激励问卷权益。
|
||||
|
||||
---
|
||||
|
||||
## 8. 开城流程
|
||||
|
||||
路径:**开城** 菜单。
|
||||
|
||||
### 8.1 步骤总览
|
||||
|
||||
```
|
||||
① 新增城市(省市区划 + 启用)
|
||||
↓
|
||||
② 配置城市合伙人(全城 / 区域 + 佣金比例)
|
||||
↓
|
||||
③ 配置仓库(可选,一城多仓)
|
||||
↓
|
||||
④ 仓配管理注册承运商(小飞侠等)并绑定仓库履约方式
|
||||
↓
|
||||
⑤ 该城 C 端按「同城」规则下单;未开城走「跨城」
|
||||
```
|
||||
|
||||
### 8.2 城市
|
||||
|
||||
- 选择省份/城市区划,填写城市名称与编码
|
||||
- 状态启用后,用户定位命中该城即走同城履约与起购规则
|
||||
- 城市详情可查看门店数、订单数、合伙人绑定、仓库列表
|
||||
|
||||
### 8.3 城市合伙人
|
||||
|
||||
- 在城市下创建或绑定合伙人账号
|
||||
- 配置管辖:全城 or 勾选区县(区县互斥)
|
||||
- 配置订单佣金比例、核销佣金比例(合计 ≤ 5%)
|
||||
- 可管理合伙人子账号(管理员/推广员)
|
||||
|
||||
### 8.4 仓库与仓配
|
||||
|
||||
| 配置 | 说明 |
|
||||
|------|------|
|
||||
| 仓库 | 名称、地址、联系人;管仓方 = 总部直派 或 关联合伙人(每仓最多 1 名) |
|
||||
| 履约方式 | **API 自动推单**(选已注册承运商,首期小飞侠)或 **自管**(手工填运单号 + 查询链接模板) |
|
||||
| 仓配管理 | 注册第三方履约接口;启用后仓库才可选 |
|
||||
|
||||
规则摘要:
|
||||
|
||||
- 同城 **有仓**:按仓配置自动推单或自管填单
|
||||
- 同城 **无仓** / **跨城**:总部传统快递填单
|
||||
- 佣金归属与仓无关;仓用于履约与工单协同
|
||||
|
||||
---
|
||||
|
||||
## 9. 开店流程
|
||||
|
||||
对齐《门店签约 SOP》与 PRD 拓店闭环。
|
||||
|
||||
### 9.1 录入主体
|
||||
|
||||
- **合伙人端**:三步向导录入(主路径)
|
||||
- **HQ 门店列表**:总部也可代建/补录(需选择开城合伙人与匹配开城城市)
|
||||
|
||||
### 9.2 三步录入
|
||||
|
||||
| 步骤 | 内容 |
|
||||
|------|------|
|
||||
| 1. 基础信息 | 门头展示名、执照全称;筛选条件参考(面积≥200㎡、客单价≥60、包房≥5 等) |
|
||||
| 2. 证照与照片 | 证照/合同/门店照片;**附件一**结构化规则 |
|
||||
| 3. 结算信息 | 法人收款或授权书 + 银行卡;短信校验 |
|
||||
|
||||
一号多店:手机号已关联其他店时需确认后继续。
|
||||
|
||||
### 9.3 状态流转
|
||||
|
||||
```
|
||||
暂存
|
||||
→ 待负责人复核
|
||||
→ 待总部审核
|
||||
→ 审核通过(待试核销)
|
||||
→ 试核销固定 100 元成功
|
||||
→ 正式入驻 + 短信通知
|
||||
→ 营业中(C 端可见)
|
||||
```
|
||||
|
||||
- HQ 可在门店详情 **通过 / 驳回**(驳回需原因)
|
||||
- 系统开关 `AUTO_APPROVE_STORE` 开启时,合伙人录店可自动审核通过(联调/试点用)
|
||||
- 营业状态由门店主账号或 HQ 调整
|
||||
|
||||
### 9.4 HQ 门店相关页
|
||||
|
||||
- 门店列表:审核、营业状态、详情
|
||||
- 门店分类 / 门店账户 / 门店资源:分类标签、账号与媒体资源维护
|
||||
|
||||
---
|
||||
|
||||
## 10. 订单处理
|
||||
|
||||
路径:**订单**(HQ);用户端「我的订单」;合伙人端「订单中心」。
|
||||
|
||||
### 10.1 状态机
|
||||
|
||||
```
|
||||
待付款 ──支付成功──► 已付款 ──履约完成 / 确认收货 / 24h 自动──► 已完成
|
||||
└─ 30 分钟未付 → 取消
|
||||
```
|
||||
|
||||
支付成功即发放权益;履约完成不重复发券。
|
||||
|
||||
### 10.2 同城订单
|
||||
|
||||
1. 用户定位/收货在已开城城市,数量满足 ≥2 瓶
|
||||
2. 支付成功 → 订单「已付款」
|
||||
3. 有仓且 API 承运商:系统自动推小飞侠等配送单
|
||||
4. 有仓自管:管仓方/总部手工填运单号
|
||||
5. 无仓:总部在订单详情走传统快递填单
|
||||
6. 送达后用户确认,或超时 24h 自动完成
|
||||
|
||||
HQ 订单详情可查看收货地址、仓信息、配送单、权益券与核销分摊、佣金快照等。
|
||||
|
||||
### 10.3 跨城订单
|
||||
|
||||
1. 收货城市未开城;数量 ≥1 箱
|
||||
2. 确认页提示物流到付
|
||||
3. 支付成功后由 **总部** 传统快递填单发货
|
||||
4. 订单佣金归总部
|
||||
|
||||
### 10.4 线下(现场)提货
|
||||
|
||||
1. 用户通过「现场提货」推广码等隐藏入口下单
|
||||
2. 支付成功 → 订单直接 **已完成** + 权益到账
|
||||
3. HQ / 合伙人可在订单列表按配送类型或推广码筛选核对
|
||||
4. 有现场推广码:订单佣金归码所属;无码归总部
|
||||
|
||||
### 10.5 订单详情查阅清单
|
||||
|
||||
| 查阅项 | 用途 |
|
||||
|--------|------|
|
||||
| 订单号 / 状态 / 金额 / 运费 | 对账与客服 |
|
||||
| 收货人、省市区、详细地址 | 履约改址(规则允许时) |
|
||||
| 定位/IP 辅助信息 | 风控与同城判断留痕 |
|
||||
| 商品行、权益额 | 发券核对 |
|
||||
| 配送类型与运单 | 同城/跨城/现场 |
|
||||
| 关联权益券与核销记录 | 售后与财务 |
|
||||
| 佣金归属快照 | 合伙人结算 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 财务模块
|
||||
|
||||
路径:**财务**。
|
||||
|
||||
### 11.1 门店账单
|
||||
|
||||
- 来源:门店核销 → 结算额 = 核销额 × **60%** → T+1 出账形成账期
|
||||
- 列表字段:账单号、账期日、核销笔数/金额、结算比例、应打款、状态(未打款/已打款)
|
||||
- 操作:查看明细、**确认打款**、批量打款、导出 Excel
|
||||
- 提现申请:门店发起 → 总部审核 → 打至入驻收款账户
|
||||
- 试点护栏:未出账提现白名单、单店单日上限(默认 ¥5,000)、工作日 T+0 审完预警
|
||||
|
||||
### 11.2 合伙人账单
|
||||
|
||||
- 订单佣金(支付成功快照)+ 核销佣金(核销时按占比释放)
|
||||
- 独立月账、独立确认、独立打款
|
||||
- 比例变更只影响新业务,历史账单展示快照
|
||||
|
||||
### 11.3 酒厂账单
|
||||
|
||||
- 总部与酒厂供货/回款往来核对
|
||||
- 打款对照 **系统设置 → 酒厂银行账户** 中的户名、开户行、账号
|
||||
|
||||
### 11.4 财务日常 SOP(建议)
|
||||
|
||||
1. 每日核对门店「未打款」账单与核销记录
|
||||
2. 处理门店提现申请并在账期内标记打款
|
||||
3. 月结合伙人账单并完成打款确认
|
||||
4. 同步发票开具与退款工单对资金影响
|
||||
5. 异常走技术支持或售后工单留痕
|
||||
|
||||
---
|
||||
|
||||
## 12. 发票模块
|
||||
|
||||
路径:**发票管理**。用户端亦可发起申请。
|
||||
|
||||
### 12.1 类型组合
|
||||
|
||||
| 抬头 | 票种 |
|
||||
|------|------|
|
||||
| 个人 `PERSONAL` | 增值税普通发票 |
|
||||
| 企业 `ENTERPRISE` | 增值税普通发票 / 增值税专用发票 |
|
||||
|
||||
企业专票通常需税号、地址电话、开户行及账号等。
|
||||
|
||||
### 12.2 状态
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| `PENDING` 待开票 | 用户或 HQ 已提交 |
|
||||
| `ISSUED` 已开票 | HQ 上传发票文件回传 |
|
||||
| `REJECTED` 已驳回 | 信息有误等 |
|
||||
|
||||
SLA:**2 个工作日**内处理;超时可在列表以逾期标识预警。
|
||||
|
||||
### 12.3 HQ 处理步骤
|
||||
|
||||
1. 按状态筛选待开票
|
||||
2. 打开详情核对订单号、金额、抬头、邮箱/手机
|
||||
3. 开具后上传 PDF/图片至 OSS,执行「开票回传」
|
||||
4. 或驳回并备注原因
|
||||
5. 亦可由客服/财务代用户创建申请(需订单号)
|
||||
|
||||
---
|
||||
|
||||
## 13. 工单模块
|
||||
|
||||
工单分两类,勿混淆。
|
||||
|
||||
### 13.1 售后工单中心(用户订单)
|
||||
|
||||
路径:**工单 → 工单中心**。
|
||||
|
||||
| 类型 | 总部决策 | 通过后动作 |
|
||||
|------|----------|------------|
|
||||
| 仅退款 | 同意/驳回 | 原路退款 |
|
||||
| 破损补发 | 同意/驳回 | 负责仓配送+取回;通知管仓合伙人 |
|
||||
| 破损退货 | 同意/驳回 | 负责仓取回 → 退款 |
|
||||
| 退货退款 | 同意/驳回 | 通知归属合伙人 + 负责仓取回 → 退款 |
|
||||
|
||||
另有系统 `ALERT` 异常类工单。
|
||||
用户从已付款及之后订单发起;客服也可在 HQ 代建。处理全程留痕,协同阶段需仓/合伙人确认。
|
||||
|
||||
### 13.2 技术支持工单
|
||||
|
||||
路径:**工单 → 技术支持**。见 [§5.5](#55-开发技术支持工单协作)。
|
||||
|
||||
与售后工单权限分离:`tickets` vs `tech_support`。
|
||||
|
||||
---
|
||||
|
||||
## 14. 配送单
|
||||
|
||||
路径:**配送单 → 配送单列表**(及「小飞侠联调」)。
|
||||
|
||||
### 14.1 列表与查询
|
||||
|
||||
可按订单号、承运商 `provider`、运单号筛选。字段含:订单号、provider、运单号、第三方单号、订单状态、收货人、更新时间。
|
||||
|
||||
### 14.2 处理动作
|
||||
|
||||
- 查看关联订单与收货信息
|
||||
- **编辑** 承运商、运单号、第三方单号(自管仓/传统快递补录)
|
||||
- API 自动推单失败时,人工改状态或重填运单作为兜底
|
||||
- 小飞侠联调页:对接调试推单、状态回调、取送拍照等
|
||||
|
||||
### 14.3 与订单的关系
|
||||
|
||||
配送单从属于订单履约;同城目标 24h;用户确认收货或超时自动完成后订单进入「已完成」。跨城到付同样在配送单/订单详情维护运单信息。
|
||||
|
||||
---
|
||||
|
||||
## 15. 好客权益
|
||||
|
||||
路径:**好客权益**。
|
||||
|
||||
### 15.1 核心规则
|
||||
|
||||
| 规则 | 值 |
|
||||
|------|-----|
|
||||
| 发放 | 支付成功,实付 × 1:1 |
|
||||
| 有效期 | 永久 |
|
||||
| 直接核销 | `0 < amount ≤ 全部 ACTIVE 权益总余额` |
|
||||
| 带单据核销 | `0 < amount ≤ 该单据可用金额` |
|
||||
| 出码 TTL | 3 分钟 |
|
||||
| 试核销 | 固定 100 元(拓店) |
|
||||
|
||||
### 15.2 HQ 子模块
|
||||
|
||||
| 页面 | 用途 |
|
||||
|------|------|
|
||||
| **权益券** | 按券号/用户/状态查询;查看余额、来源订单商品;详情含核销汇总与记录;支持人工发放(运营补偿) |
|
||||
| **流水** | 发放/扣减等账本流水审计 |
|
||||
| **核销记录** | 全门店核销明细;结算额核对 |
|
||||
| **待处理核销** | 弱网拍照等人工业务单;T+0 补核销 |
|
||||
| **核销调试** | 联调/排障工具 |
|
||||
|
||||
### 15.3 处理原则
|
||||
|
||||
- 补发券、作废、补核销必须留备注与操作日志
|
||||
- 退款类售后需同步评估是否回收未使用权益(按工单审批结果执行)
|
||||
- 门店结算与核销记录交叉核对,避免重复入账
|
||||
|
||||
---
|
||||
|
||||
## 16. 系统设置模块
|
||||
|
||||
路径:**系统设置**。按分组授权(非超管可能只见部分 Tab)。
|
||||
敏感基础设施(数据库、JWT、端口等)**仅存服务器 `.env`**,不在本页维护。
|
||||
|
||||
### 16.1 功能开关
|
||||
|
||||
| 配置项 | 作用 |
|
||||
|--------|------|
|
||||
| Mock 短信 | 不发真实短信;验证码入库可查 |
|
||||
| Mock 支付 | 关闭且商户参数齐全时走真实 JSAPI |
|
||||
| Mock 微信授权 | 关闭且 AppID/Secret 齐全时走真实微信 |
|
||||
| Mock 配送自动完成 | 联调自动推进配送状态 |
|
||||
| 门店自动审核通过 | 录店免人工审(试点) |
|
||||
| 启用企微机器人长连接 | 总开关;开启后连接 HQ「企微机器人」中已启用实例(见 §17) |
|
||||
|
||||
标注「即时」的项保存后写入运行配置即可生效;「需重启」项改完需重启 API。
|
||||
企微 Bot 的 BotID/Secret **不在系统设置里配置**,改在独立菜单 **企微机器人**。
|
||||
|
||||
### 16.2 短信
|
||||
|
||||
- 签名、默认模板、核销确认模板、代下单模板
|
||||
- 阿里云 AccessKey(密钥类需重启)
|
||||
- Mock 开启时可在页内查看最近验证码列表
|
||||
|
||||
### 16.3 微信
|
||||
|
||||
- 服务号 AppID/Secret
|
||||
- 小程序 AppID/Secret
|
||||
- 商户号、证书序列号、商户私钥、APIv3 密钥、平台证书
|
||||
- 支付回调 URL
|
||||
|
||||
用于登录授权、JSAPI/小程序支付、回调验签。
|
||||
|
||||
### 16.4 微信小程序展示
|
||||
|
||||
- 首页轮播图(建议 15:8,最多 8 张)
|
||||
- 首页底部图(建议 15:4)
|
||||
上传后需点击 **保存**。
|
||||
|
||||
### 16.5 对象存储 OSS
|
||||
|
||||
AccessKey、Bucket、Region、Endpoint、CDN 域名、上传前缀、凭证有效期、单文件大小上限等。商品图、发票文件、门店证照均依赖此配置。
|
||||
|
||||
### 16.6 应用链接
|
||||
|
||||
- C 端 H5 落地页(推广码二维码链接前缀)
|
||||
- 腾讯位置服务 Key(逆地理/热力图等)
|
||||
|
||||
### 16.7 发布部署
|
||||
|
||||
Webhook URL / Secret,供发版流水线回调(与 `@dukang-release` 发布流程配合)。
|
||||
|
||||
### 16.8 酒厂银行账户
|
||||
|
||||
户名、开户银行、支行、账号——财务打款对照。
|
||||
|
||||
### 16.9 相关管理页
|
||||
|
||||
| 页面 | 说明 |
|
||||
|------|------|
|
||||
| 权限分配 | 角色默认权限 + 账号额外权限 |
|
||||
| HQ 账户 | 创建总部登录账号并指定角色 |
|
||||
| 日志 | 用户/商户/合伙人/HQ 操作日志、第三方调用日志 |
|
||||
|
||||
---
|
||||
|
||||
## 17. 企业微信对接
|
||||
|
||||
本节区分两套能力,勿混用:
|
||||
|
||||
1. **C 端在线客服链接**(用户进人工会话)
|
||||
2. **HQ 企微智能机器人**(内部同事用指令操作业务;命令式,**不依赖大语言模型**)
|
||||
|
||||
### 17.1 在线客服(微信客服 / 企微客服)
|
||||
|
||||
C 端「联系客服 → 在线客服」跳转企业微信 **微信客服** 链接,在微信内打开后进入原生客服会话。
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 默认链接 | 配置于 `packages/shared-types` 的 `CUSTOMER_SERVICE_WECOM_URL` |
|
||||
| 前端覆盖 | H5 可用环境变量 `VITE_CS_WECOM_URL` |
|
||||
| 电话兜底 | `CUSTOMER_SERVICE_PHONE`(如 400 热线) |
|
||||
| 使用限制 | 须在 **微信内** 打开;需用户点击手势触发 |
|
||||
|
||||
小程序端优先使用小程序客服能力;非微信环境提示拨打电话。
|
||||
|
||||
### 17.2 HQ 企微机器人(智能机器人长连接)
|
||||
|
||||
入口:**HQ → 企微机器人**(权限键 `wecom_bots`)。
|
||||
技术通道:企业微信「智能机器人」长连接 SDK;产品名带「智能」,本系统实现为 **关键词/指令路由**,**不必接入 AI 语言模型即可生效**。
|
||||
|
||||
#### 配置字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 名称 | 会话内展示用名称 |
|
||||
| 角色 | 客服 / 技术支持 / 团队助手 / 自定义(决定默认权限) |
|
||||
| BotID / Secret | 企微管理后台创建智能机器人后下发;每实例一对 |
|
||||
| 头像 | OSS 上传(可选) |
|
||||
| 欢迎语 | 进入会话时推送(可选) |
|
||||
| 权限 | 可勾选能力;创建时按角色带出默认值,可改 |
|
||||
| 启用 AI 问答 | 未匹配指令时调用绑定的语言模型 |
|
||||
| 语言模型 | 选自 HQ「语言模型」中已生效配置(非超管仅能选自己创建的) |
|
||||
| 知识库 | 选自 HQ「知识库」;检索片段注入模型上下文 |
|
||||
| 启用 | 关闭则不建立长连接 |
|
||||
|
||||
保存后可用页内 **重载连接**,或依赖总开关变更后的自动重载。每个已启用 Bot 同时仅保持 **1 条** 长连接。
|
||||
|
||||
#### 语言模型与知识库(与企微同级菜单)
|
||||
|
||||
| 模块 | 权限键 | 规则摘要 |
|
||||
|------|--------|----------|
|
||||
| 语言模型 | `llm_configs` | 可选 DeepSeek / OpenAI / 通义千问 / 自定义 OpenAI 兼容 API。**超级管理员**可看改删全部;**其他 HQ** 只能看到自己创建的配置,创建后仅能改「是否生效」,不能改 Key/模型等,也不能删除 |
|
||||
| 知识库 | `knowledge_bases` | 粘贴正文或上传 `.txt/.md` 等文本;非超管仅管理自己创建的库 |
|
||||
|
||||
企微侧流程:先建语言模型(并测试)→ 建知识库并上传文档 → 机器人表单开启 AI 并绑定。指令仍优先;仅未匹配时走模型。
|
||||
|
||||
#### 总开关
|
||||
|
||||
**系统设置 → 功能开关 → 启用企微机器人长连接**(`WECOM_AIBOT_ENABLED`)。
|
||||
总开关关闭时,即使 HQ 里已配置 Bot,也不连企微。
|
||||
|
||||
#### 预置角色与默认权限
|
||||
|
||||
| 角色 | 默认权限 | 典型用途 |
|
||||
|------|----------|----------|
|
||||
| 客服机器人 | 创建售后工单;查用户(短信验证);查快递 | 客服在企微会话内快速建单、核身份、跟物流 |
|
||||
| 技术支持机器人 | 创建技术支持工单;查看开发进度 | 内部提单 BUG/建议、查工单状态 |
|
||||
| 团队助手 | 查询使用手册 | 按关键词答运营/协作常识(摘自知识库摘要) |
|
||||
| 自定义 | 无(自行勾选) | 组合权限 |
|
||||
|
||||
权限目录:`ticket.create` · `user.view_sms` · `delivery.view` · `support_ticket.create` · `support_ticket.progress` · `handbook.query`。
|
||||
|
||||
#### 常用指令(会话内发文本)
|
||||
|
||||
通用:`帮助` · `状态`
|
||||
|
||||
| 权限 | 指令示例 |
|
||||
|------|----------|
|
||||
| 售后工单 | `工单 <订单号> <类型> [备注]`(类型:仅退款 / 破损补发 / 破损退货 / 退货退款) |
|
||||
| 查用户 | `查用户 <手机号>` → `验证 <验证码>`(验证通过后返回用户摘要) |
|
||||
| 查快递 | `快递 <订单号\|运单号>` |
|
||||
| 技术支持提单 | `提单 <BUG\|建议\|其他> <标题> [| 详情]` |
|
||||
| 开发进度 | `进度`(最近)· `进度 <工单号>` |
|
||||
| 使用手册 | `手册`(目录)· `手册 <关键词>`(如:开城、核销、订单) |
|
||||
|
||||
未匹配指令时:若已启用 AI 并绑定模型,则走语言模型(可带知识库);否则回复「帮助」文案。暂仅支持 **文本**。
|
||||
|
||||
> 接语言模型是**可选增强**,不是长连接生效的前提;纯指令机器人仍可独立使用。
|
||||
|
||||
#### 启用步骤(运营/研发)
|
||||
|
||||
1. 企业微信管理后台创建「智能机器人」,取得 BotID、Secret
|
||||
2. HQ「企微机器人」创建实例,填名称/角色/凭证/权限并启用
|
||||
3. 系统设置打开 **启用企微机器人长连接**
|
||||
4. 在企微中把机器人加到会话,发送 `帮助` 验证
|
||||
5. 改配置或凭证后点 **重载连接**(或重启 API)
|
||||
|
||||
> **说明**:不接大模型也能完成指令表能力。开启 AI + 知识库后,可用自然语言问答;写操作(建单等)仍建议走固定指令。
|
||||
|
||||
### 17.3 与总部客服 / 技术支持的协作
|
||||
|
||||
**人工客服链路(C 端用户)**
|
||||
|
||||
1. 用户通过在线客服链接进入企微/微信客服会话,说明问题并提供订单号
|
||||
2. HQ **客服** 在用户/订单/工单中心处理,或经 **客服机器人** 用指令建售后工单
|
||||
3. 需研发介入时,在 HQ 或经 **技术支持机器人** 创建技术支持工单,超管评审
|
||||
4. 退款/补发结果回传用户(短信或会话)
|
||||
|
||||
**内部同事**:优先用对应角色机器人发指令,减少反复进后台翻页;敏感查用户仍须短信验证码。
|
||||
|
||||
### 17.4 微信生态相关(易混淆对照)
|
||||
|
||||
| 能力 | 是否企微 | 配置位置 |
|
||||
|------|----------|----------|
|
||||
| C 端微信客服链接 | 是(客服入口) | 代码常量 / `VITE_CS_WECOM_URL` |
|
||||
| HQ 企微智能机器人 | 是(长连接指令助手) | HQ「企微机器人」+ 功能开关总开关 |
|
||||
| 服务号 OAuth / 支付 | 否(微信开放平台/商户) | 系统设置 → 微信 |
|
||||
| 小程序登录支付与首页素材 | 否 | 系统设置 → 微信 / 微信小程序 |
|
||||
| 微信绑定查询 | 否 | HQ「微信绑定」页(OpenID/UnionID 多端身份) |
|
||||
|
||||
### 17.5 运营注意
|
||||
|
||||
- 更换 C 端客服链接时同步改 shared-types 默认值或各端环境变量并重新发布前端
|
||||
- 企微侧客服账号、机器人可见范围、会话分配在 **企业微信管理后台** 维护;本系统不托管企微通讯录
|
||||
- Bot Secret 仅 HQ 创建/编辑时写入,列表不回明文;泄露后应在企微后台重置并更新 HQ 配置后重载
|
||||
- 工作时间话术与 PRD OPT-012 对齐(上线后 3 日内更新)
|
||||
|
||||
---
|
||||
|
||||
## 附录 A · 端职责矩阵
|
||||
|
||||
| 能力 | 用户端 | 门店端 | 合伙人端 | 总部端 |
|
||||
|------|:------:|:------:|:--------:|:------:|
|
||||
| 下单/支付/权益 | ● | | 代下单 | 代下单 |
|
||||
| 核销 | 出码 | ● | 看记录 | 看全量 |
|
||||
| 拓店 | 看门店 | 改营业 | ●录入 | ●审核 |
|
||||
| 工单 | ●发起 | 查看 | 查看 | ●决策 |
|
||||
| 结算提现 | | ● | 对账确认 | ●审打款 |
|
||||
| 推广码/问卷/评价 | ● | 被评价 | 看数据 | 配置/看板 |
|
||||
|
||||
## 附录 B · 相关文档
|
||||
|
||||
| 文档 | 用途 |
|
||||
|------|------|
|
||||
| `杜康好客-v3-PRD.md` | 唯一需求事实源 |
|
||||
| `杜康好客-v3-现状对照.md` | 已完成 / 缺口审计 |
|
||||
| `杜康好客-v3编码手册.md` | 实现与验收 |
|
||||
| `杜康好客-v3-城市仓库与日志架构.md` | 开城/仓/日志技术说明 |
|
||||
| `AGENTS.md` / `conventions.md` | 工程协作与模块边界 |
|
||||
| `pages/ROUTE_MAP.md` | 原型屏与路由对照 |
|
||||
|
||||
## 附录 C · 变更说明
|
||||
|
||||
本知识库用于培训与协作,**不替代 PRD**。若业务规则变更,须先改 `杜康好客-v3-PRD.md`,再同步本文件与编码手册。
|
||||
Reference in New Issue
Block a user