v4.0.17企业微信API插件优化
This commit is contained in:
@@ -50,6 +50,7 @@ import HqPermissionsPage from './pages/HqPermissionsPage';
|
|||||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||||
import TestWhitelistPage from './pages/TestWhitelistPage';
|
import TestWhitelistPage from './pages/TestWhitelistPage';
|
||||||
import WecomBotsPage from './pages/WecomBotsPage';
|
import WecomBotsPage from './pages/WecomBotsPage';
|
||||||
|
import WecomApiPluginsPage from './pages/WecomApiPluginsPage';
|
||||||
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
|
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
|
||||||
import WecomReportsPage from './pages/WecomReportsPage';
|
import WecomReportsPage from './pages/WecomReportsPage';
|
||||||
import WecomBotLogsPage from './pages/WecomBotLogsPage';
|
import WecomBotLogsPage from './pages/WecomBotLogsPage';
|
||||||
@@ -95,6 +96,7 @@ export default function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="/wecom-bots" element={<Navigate to="/wecom/bots" replace />} />
|
<Route path="/wecom-bots" element={<Navigate to="/wecom/bots" replace />} />
|
||||||
<Route path="/wecom/bots" element={<WecomBotsPage />} />
|
<Route path="/wecom/bots" element={<WecomBotsPage />} />
|
||||||
|
<Route path="/wecom/plugins" element={<WecomApiPluginsPage />} />
|
||||||
<Route path="/wecom/pushes" element={<WecomMessagePushesPage />} />
|
<Route path="/wecom/pushes" element={<WecomMessagePushesPage />} />
|
||||||
<Route path="/wecom/reports" element={<WecomReportsPage />} />
|
<Route path="/wecom/reports" element={<WecomReportsPage />} />
|
||||||
<Route path="/logs/wecom-bots" element={<WecomBotLogsPage />} />
|
<Route path="/logs/wecom-bots" element={<WecomBotLogsPage />} />
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
label: '企微机器人',
|
label: '企微机器人',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/wecom/bots', label: '智能机器人' },
|
{ key: '/wecom/bots', label: '智能机器人' },
|
||||||
|
{ key: '/wecom/plugins', label: 'API 插件' },
|
||||||
{ key: '/wecom/pushes', label: '消息推送' },
|
{ key: '/wecom/pushes', label: '消息推送' },
|
||||||
{ key: '/wecom/reports', label: '报告' },
|
{ key: '/wecom/reports', label: '报告' },
|
||||||
],
|
],
|
||||||
@@ -186,6 +187,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
|||||||
'/promo-codes': 'promo_codes',
|
'/promo-codes': 'promo_codes',
|
||||||
'/activity-posters': 'activity_posters',
|
'/activity-posters': 'activity_posters',
|
||||||
'/wecom/bots': 'wecom_bots',
|
'/wecom/bots': 'wecom_bots',
|
||||||
|
'/wecom/plugins': 'wecom_bots',
|
||||||
'/wecom/pushes': 'wecom_bots',
|
'/wecom/pushes': 'wecom_bots',
|
||||||
'/wecom/reports': 'wecom_bots',
|
'/wecom/reports': 'wecom_bots',
|
||||||
'wecom-group': 'wecom_bots',
|
'wecom-group': 'wecom_bots',
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
WECOM_PLUGIN_API_KEY_HEADER,
|
||||||
|
WECOM_PLUGIN_PERMISSION_GROUPS,
|
||||||
|
WECOM_PLUGIN_PERMISSION_LABELS,
|
||||||
|
resolveWecomPluginPublicUrl,
|
||||||
|
type WecomApiPluginDto,
|
||||||
|
type WecomApiPluginSecretDto,
|
||||||
|
type WecomPluginPermission,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
name: string;
|
||||||
|
apiKey?: string;
|
||||||
|
permissions: WecomPluginPermission[];
|
||||||
|
remark?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WecomApiPluginsPage() {
|
||||||
|
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<WecomApiPluginDto | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [rotatingId, setRotatingId] = useState<string | null>(null);
|
||||||
|
const [revealed, setRevealed] = useState<WecomApiPluginSecretDto | null>(null);
|
||||||
|
|
||||||
|
const pluginUrl = resolveWecomPluginPublicUrl(window.location.hostname);
|
||||||
|
const permissionsWatch = Form.useWatch('permissions', form) as WecomPluginPermission[] | undefined;
|
||||||
|
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||||||
|
useAdminList<WecomApiPluginDto>(
|
||||||
|
'/admin/wecom-api-plugins',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.name) qs.set('name', filters.name);
|
||||||
|
if (filters.enabled) qs.set('enabled', filters.enabled);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue({
|
||||||
|
enabled: true,
|
||||||
|
permissions: [],
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: WecomApiPluginDto) {
|
||||||
|
setEditing(row);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: row.name,
|
||||||
|
apiKey: '',
|
||||||
|
permissions: [...row.permissions],
|
||||||
|
remark: row.remark ?? undefined,
|
||||||
|
enabled: row.enabled,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name: values.name,
|
||||||
|
permissions: values.permissions,
|
||||||
|
remark: values.remark?.trim() || null,
|
||||||
|
enabled: values.enabled,
|
||||||
|
sortOrder: values.sortOrder ?? 0,
|
||||||
|
apiKey: values.apiKey?.trim() || undefined,
|
||||||
|
};
|
||||||
|
if (editing) {
|
||||||
|
await request(`/admin/wecom-api-plugins/${editing.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
message.success('已更新');
|
||||||
|
} else {
|
||||||
|
const created = await request<WecomApiPluginSecretDto>('/admin/wecom-api-plugins', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setRevealed(created);
|
||||||
|
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-api-plugins/${id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateKey(id: string) {
|
||||||
|
setRotatingId(id);
|
||||||
|
try {
|
||||||
|
const row = await request<WecomApiPluginSecretDto>(`/admin/wecom-api-plugins/${id}/rotate-key`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
setRevealed(row);
|
||||||
|
message.success('已轮换,请立即复制新密钥');
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '轮换失败');
|
||||||
|
} finally {
|
||||||
|
setRotatingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<WecomApiPluginDto> = [
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (name: string, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '密钥', dataIndex: 'apiKeyMasked', width: 160 },
|
||||||
|
{
|
||||||
|
title: '权限',
|
||||||
|
dataIndex: 'permissions',
|
||||||
|
render: (perms: WecomPluginPermission[]) =>
|
||||||
|
perms.map((p) => (
|
||||||
|
<Tag key={p} style={{ marginBottom: 4 }}>
|
||||||
|
{WECOM_PLUGIN_PERMISSION_LABELS[p]}
|
||||||
|
</Tag>
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '启用',
|
||||||
|
dataIndex: 'enabled',
|
||||||
|
width: 72,
|
||||||
|
render: (v: boolean) => (v ? <Tag color="green">是</Tag> : <Tag>否</Tag>),
|
||||||
|
},
|
||||||
|
{ title: '排序', dataIndex: 'sortOrder', width: 64 },
|
||||||
|
{
|
||||||
|
title: '更新时间',
|
||||||
|
dataIndex: 'updatedAt',
|
||||||
|
width: 170,
|
||||||
|
render: (v: string) => fmtTime(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 220,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space wrap>
|
||||||
|
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="轮换后旧 Key 立即失效,确认?"
|
||||||
|
onConfirm={() => void rotateKey(row.id)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" loading={rotatingId === row.id}>
|
||||||
|
轮换密钥
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => void remove(row.id)}>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns(
|
||||||
|
'wecom-api-plugins',
|
||||||
|
baseColumns,
|
||||||
|
{ page, pageSize },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader
|
||||||
|
settings={settingsButton}
|
||||||
|
description="每个实例对应企微里一只 API 插件:共用 Base URL,用不同 X-Api-Key 区分,并按勾选权限放行工具。"
|
||||||
|
actions={
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
新建插件
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="填企微「添加 API 插件」第 1 步"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
插件 URL:
|
||||||
|
<Typography.Text copyable>{pluginUrl}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
OpenAPI(带该实例 Key):
|
||||||
|
<Typography.Text copyable>{`${pluginUrl}/openapi.json`}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
授权方式 Service token / API key,Header 名 {WECOM_PLUGIN_API_KEY_HEADER}
|
||||||
|
;第 2 步只添加本实例已勾选的工具(GET + Query)。
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={filterForm}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters(v as Record<string, string>);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="name" label="名称">
|
||||||
|
<Input allowClear placeholder="搜索" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="enabled" label="启用">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部"
|
||||||
|
style={{ width: 100 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'true', label: '启用' },
|
||||||
|
{ value: 'false', label: '停用' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
filterForm.resetFields();
|
||||||
|
setFilters({});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table<WecomApiPluginDto>
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? '编辑 API 插件' : '新建 API 插件'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => void save()}
|
||||||
|
confirmLoading={saving}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" preserve={false}>
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||||
|
<Input placeholder="如:运营查询、客服只读" maxLength={64} />
|
||||||
|
</Form.Item>
|
||||||
|
{!editing ? (
|
||||||
|
<Form.Item
|
||||||
|
name="apiKey"
|
||||||
|
label="API Key"
|
||||||
|
extra="留空则服务端自动生成;创建后只回显一次"
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="留空自动生成" />
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<Form.Item label="API Key">
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{editing.apiKeyMasked}(列表不回显明文,需要时请轮换)
|
||||||
|
</Typography.Text>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
<Form.Item
|
||||||
|
name="permissions"
|
||||||
|
label="工具权限"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
validator: (_, v: WecomPluginPermission[] | undefined) =>
|
||||||
|
v?.length ? Promise.resolve() : Promise.reject(new Error('请至少勾选一项')),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
extra={
|
||||||
|
permissionsWatch?.length ? (
|
||||||
|
<Typography.Text type="secondary">已选 {permissionsWatch.length} 项</Typography.Text>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Checkbox.Group style={{ width: '100%' }}>
|
||||||
|
{WECOM_PLUGIN_PERMISSION_GROUPS.map((group) => (
|
||||||
|
<div key={group.key} style={{ marginBottom: 12 }}>
|
||||||
|
<Typography.Text strong>{group.label}</Typography.Text>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
{group.permissions.map((p) => (
|
||||||
|
<Checkbox key={p} value={p} style={{ marginInlineStart: 0, marginRight: 12 }}>
|
||||||
|
{WECOM_PLUGIN_PERMISSION_LABELS[p]}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Checkbox.Group>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={2} maxLength={256} placeholder="企微侧插件名等" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sortOrder" label="排序">
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="请立即复制密钥"
|
||||||
|
open={!!revealed}
|
||||||
|
onCancel={() => setRevealed(null)}
|
||||||
|
footer={[
|
||||||
|
<Button key="ok" type="primary" onClick={() => setRevealed(null)}>
|
||||||
|
已复制
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{revealed ? (
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
实例「{revealed.name}」的 {WECOM_PLUGIN_API_KEY_HEADER},关闭后不再展示。
|
||||||
|
</p>
|
||||||
|
<Typography.Text copyable>{revealed.apiKey}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -26,8 +27,6 @@ import {
|
|||||||
WECOM_BOT_ROLE_DEFAULT_PERMISSIONS,
|
WECOM_BOT_ROLE_DEFAULT_PERMISSIONS,
|
||||||
WECOM_BOT_ROLE_LABELS,
|
WECOM_BOT_ROLE_LABELS,
|
||||||
WECOM_BOT_ROLES,
|
WECOM_BOT_ROLES,
|
||||||
WECOM_PLUGIN_API_KEY_HEADER,
|
|
||||||
resolveWecomPluginPublicUrl,
|
|
||||||
type LlmApiConfigOptionDto,
|
type LlmApiConfigOptionDto,
|
||||||
type KnowledgeBaseOptionDto,
|
type KnowledgeBaseOptionDto,
|
||||||
type WecomAibotRuntimeDto,
|
type WecomAibotRuntimeDto,
|
||||||
@@ -410,27 +409,13 @@ export default function WecomBotsPage() {
|
|||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
message="企微 API 插件(与上方长连接机器人独立)"
|
message="企微 API 插件已迁入独立页"
|
||||||
description={
|
description={
|
||||||
<div>
|
<span>
|
||||||
<div>
|
多实例、密钥与工具权限请到{' '}
|
||||||
插件 URL:
|
<Link to="/wecom/plugins">企微机器人 → API 插件</Link>
|
||||||
<Typography.Text copyable>
|
{' '}管理,与上方长连接机器人独立。
|
||||||
{resolveWecomPluginPublicUrl(window.location.hostname)}
|
</span>
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
OpenAPI:
|
|
||||||
<Typography.Text copyable>
|
|
||||||
{`${resolveWecomPluginPublicUrl(window.location.hostname)}/openapi.json`}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
授权方式 Service token / API key,Header 名 {WECOM_PLUGIN_API_KEY_HEADER}
|
|
||||||
;密钥只在服务器 .env 的 WECOM_PLUGIN_API_KEY,本页不展示。须先开
|
|
||||||
WECOM_PLUGIN_ENABLED=true。
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 推送当前 dev_jacy 分支到远程 origin/dev_jacy
|
||||||
|
# 用法: deploy/git-push-dev-jacy.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
GIT_REMOTE="${GIT_REMOTE:-origin}"
|
||||||
|
DEV_BRANCH="${DEV_BRANCH:-dev_jacy}"
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo "ERROR: 工作区有未提交更改,请先 commit 或 stash" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT="$(git branch --show-current)"
|
||||||
|
if [[ "$CURRENT" != "$DEV_BRANCH" ]]; then
|
||||||
|
echo "ERROR: 当前分支为 $CURRENT,请先 checkout $DEV_BRANCH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> fetch $GIT_REMOTE"
|
||||||
|
git fetch "$GIT_REMOTE"
|
||||||
|
|
||||||
|
echo "==> push $DEV_BRANCH -> $GIT_REMOTE/$DEV_BRANCH"
|
||||||
|
git push -u "$GIT_REMOTE" "$DEV_BRANCH"
|
||||||
|
|
||||||
|
echo "==> 完成: $(git rev-parse --short HEAD) 已推送到 $GIT_REMOTE/$DEV_BRANCH"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 将 dev_jacy 合并到 dev 并推送(staging 测试分支)
|
||||||
|
# 用法: deploy/git-sync-to-dev.sh [merge message]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
GIT_REMOTE="${GIT_REMOTE:-origin}"
|
||||||
|
DEV_BRANCH="${DEV_BRANCH:-dev_jacy}"
|
||||||
|
STAGING_BRANCH="${STAGING_BRANCH:-dev}"
|
||||||
|
|
||||||
|
MERGE_MSG="${1:-merge($DEV_BRANCH): sync to $STAGING_BRANCH}"
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo "ERROR: 工作区有未提交更改,请先 commit 或 stash" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ORIGINAL="$(git branch --show-current)"
|
||||||
|
|
||||||
|
echo "==> fetch $GIT_REMOTE"
|
||||||
|
git fetch "$GIT_REMOTE"
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/heads/$DEV_BRANCH"; then
|
||||||
|
echo "==> push $DEV_BRANCH"
|
||||||
|
git push "$GIT_REMOTE" "$DEV_BRANCH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> checkout $STAGING_BRANCH"
|
||||||
|
git checkout "$STAGING_BRANCH"
|
||||||
|
git pull "$GIT_REMOTE" "$STAGING_BRANCH"
|
||||||
|
|
||||||
|
echo "==> merge $DEV_BRANCH -> $STAGING_BRANCH"
|
||||||
|
git merge "$DEV_BRANCH" -m "$MERGE_MSG"
|
||||||
|
|
||||||
|
echo "==> push $STAGING_BRANCH"
|
||||||
|
git push "$GIT_REMOTE" "$STAGING_BRANCH"
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/heads/$DEV_BRANCH"; then
|
||||||
|
echo "==> checkout $DEV_BRANCH"
|
||||||
|
git checkout "$DEV_BRANCH"
|
||||||
|
elif [[ "$ORIGINAL" != "$STAGING_BRANCH" ]]; then
|
||||||
|
echo "==> checkout $ORIGINAL"
|
||||||
|
git checkout "$ORIGINAL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> 完成: $STAGING_BRANCH = $(git rev-parse --short HEAD)"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 将 dev 合并到 main 并推送(生产分支,不含部署)
|
||||||
|
# 用法: deploy/git-sync-to-main.sh [merge message]
|
||||||
|
# 合并后发生产: deploy/deploy-prod.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
GIT_REMOTE="${GIT_REMOTE:-origin}"
|
||||||
|
DEV_BRANCH="${DEV_BRANCH:-dev_jacy}"
|
||||||
|
STAGING_BRANCH="${STAGING_BRANCH:-dev}"
|
||||||
|
PROD_BRANCH="${PROD_BRANCH:-main}"
|
||||||
|
|
||||||
|
MERGE_MSG="${1:-merge($STAGING_BRANCH): sync to $PROD_BRANCH}"
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo "ERROR: 工作区有未提交更改,请先 commit 或 stash" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ORIGINAL="$(git branch --show-current)"
|
||||||
|
|
||||||
|
echo "==> fetch $GIT_REMOTE"
|
||||||
|
git fetch "$GIT_REMOTE"
|
||||||
|
|
||||||
|
echo "==> checkout $PROD_BRANCH"
|
||||||
|
git checkout "$PROD_BRANCH"
|
||||||
|
git pull "$GIT_REMOTE" "$PROD_BRANCH"
|
||||||
|
|
||||||
|
echo "==> merge $STAGING_BRANCH -> $PROD_BRANCH"
|
||||||
|
git merge "$STAGING_BRANCH" -m "$MERGE_MSG"
|
||||||
|
|
||||||
|
echo "==> push $PROD_BRANCH"
|
||||||
|
git push "$GIT_REMOTE" "$PROD_BRANCH"
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/heads/$DEV_BRANCH"; then
|
||||||
|
echo "==> checkout $DEV_BRANCH"
|
||||||
|
git checkout "$DEV_BRANCH"
|
||||||
|
elif [[ "$ORIGINAL" != "$PROD_BRANCH" ]]; then
|
||||||
|
echo "==> checkout $ORIGINAL"
|
||||||
|
git checkout "$ORIGINAL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> 完成: $PROD_BRANCH = $(git rev-parse --short HEAD)"
|
||||||
|
echo " 发生产请执行: bash deploy/deploy-prod.sh"
|
||||||
@@ -69,9 +69,11 @@
|
|||||||
| 3.5.12 | [`订单大屏 BGM + HQ 中文展示 + 修复删除门店分类 + HQ 侧栏顺序`](./杜康好客-v3.5.12-开发文档.md) | 🔶 开发完成 |
|
| 3.5.12 | [`订单大屏 BGM + HQ 中文展示 + 修复删除门店分类 + HQ 侧栏顺序`](./杜康好客-v3.5.12-开发文档.md) | 🔶 开发完成 |
|
||||||
| 3.5.14 | [`提交订单/支付成功日志端回填 USER_MINI`](./杜康好客-v3.5.14-开发文档.md) | 🔶 开发完成 |
|
| 3.5.14 | [`提交订单/支付成功日志端回填 USER_MINI`](./杜康好客-v3.5.14-开发文档.md) | 🔶 开发完成 |
|
||||||
| 3.5.16 | [`企微智能机器人 API 插件`](./杜康好客-v3.5.16-开发文档.md) | 🔶 开发完成 |
|
| 3.5.16 | [`企微智能机器人 API 插件`](./杜康好客-v3.5.16-开发文档.md) | 🔶 开发完成 |
|
||||||
|
| 3.5.17 | [`企微 API 插件迁入 HQ 后台`](./杜康好客-v3.5.17-开发文档.md) | 🔶 开发完成 |
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| 2026-09-06 | v3.5.17:企微 API 插件迁入 HQ「企微机器人 → API 插件」;多实例 Key + 工具权限;不再依赖 `WECOM_PLUGIN_*` env |
|
||||||
| 2026-09-03 | v3.5.16:企微 API 插件只读数据面 `GET /api/v1/wecom/plugin/*`(X-Api-Key);与长连接 Bot 独立 |
|
| 2026-09-03 | v3.5.16:企微 API 插件只读数据面 `GET /api/v1/wecom/plugin/*`(X-Api-Key);与长连接 Bot 独立 |
|
||||||
| 2026-08-30 | HQ 门店账单确认打款支持上传凭证照片(`payment_proof_urls`) |
|
| 2026-08-30 | HQ 门店账单确认打款支持上传凭证照片(`payment_proof_urls`) |
|
||||||
| 2026-08-26 | v3.5.14:`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` |
|
| 2026-08-26 | v3.5.14:`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` |
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# 杜康好客 · v3.5.17 开发文档
|
||||||
|
|
||||||
|
> **2026-09-06** · integrations/wecom · ops · admin-web · domain · shared-types
|
||||||
|
> **主题**:企微 API 插件迁入 HQ 后台(多实例 + 动态工具权限)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
把企微 API 插件从 `.env` 单 Key 迁到 HQ「企微机器人 → API 插件」:支持多实例,每实例一把 `X-Api-Key` 与一组工具权限。企微侧仍用同一 Base URL,各插件填不同 Key,第 2 步只配置已授权工具。
|
||||||
|
|
||||||
|
**不做**:改长连接 Bot / 消息推送 / 报告;新增 HQ 权限键(继续 `wecom_bots`);插件写操作;每实例独立 Base Path。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 映射约定
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|----|-----|
|
||||||
|
| 一条 HQ 实例 | 企微里一只「API 插件」 |
|
||||||
|
| Base URL | `/api/v1/wecom/plugin`(生产/测试域名不变) |
|
||||||
|
| 区分实例 | Header `X-Api-Key` |
|
||||||
|
| 未授权工具 | 403 |
|
||||||
|
| `GET /openapi.json` | 按当前 Key 的 permissions 过滤 paths |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 表 `wecom_api_plugin`
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| name | 展示名 |
|
||||||
|
| api_key | 明文存库(列表只返 `apiKeyConfigured` / `apiKeyMasked`) |
|
||||||
|
| permissions | JSON 字符串数组:`order.read` · `user.read` · `store.read` · `redeem.read` · `promo.read` · `metrics.read` |
|
||||||
|
| remark / enabled / sort_order | 备注、启停、排序 |
|
||||||
|
|
||||||
|
迁移:`pnpm --filter @dukang/api prisma:migrate-wecom-api-plugin`(或 `db push`)。
|
||||||
|
|
||||||
|
启动时若表为空且 env 仍有 `WECOM_PLUGIN_ENABLED=true` + `WECOM_PLUGIN_API_KEY`,一次性 upsert「迁移自 env」(全权限),避免断服。新部署不再依赖这两项 env。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 运行时
|
||||||
|
|
||||||
|
- Guard:读 `X-Api-Key`,在 `enabled=true` 实例中常量时间比对,命中则挂 `req.wecomPlugin`
|
||||||
|
- 无启用实例 / Key 未命中 → 401
|
||||||
|
- 各工具入口 `requirePerm`;无权限 → 403
|
||||||
|
- `GET /`:该实例 name + 已授权 tools
|
||||||
|
- 审计:`log_wecom_bot.botKey=plugin:{id}`,`action=plugin.order.read` 等
|
||||||
|
|
||||||
|
### 工具权限目录(v3.5.17 增补)
|
||||||
|
|
||||||
|
| 权限 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| `metrics.read` | `/metrics`(含 `newStores` / `storesIncrement` 新增门店) |
|
||||||
|
| `store.read` | `/stores`(名称搜索 + 评分/核销/累计核销/合伙人) |
|
||||||
|
| `store.audit.read` | `/store-audits`、`/store-info-audits`、`/store-package-audits` 及 `/{id}` 对比详情 |
|
||||||
|
| `partner.read` | `/partners`、`/partners/{id}/users|stores|orders`(可选 `from`/`to` 时间筛选) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Admin API
|
||||||
|
|
||||||
|
权限键 `wecom_bots`。列表/详情永不回显完整 Key;创建与 `POST /:id/rotate-key` 一次性返回明文。
|
||||||
|
|
||||||
|
| 方法 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| GET/POST | `/admin/wecom-api-plugins` |
|
||||||
|
| GET/PUT/DELETE | `/admin/wecom-api-plugins/:id` |
|
||||||
|
| POST | `/admin/wecom-api-plugins/:id/rotate-key` |
|
||||||
|
|
||||||
|
创建时未填 `apiKey` 则服务端生成。更新时 `apiKey` 空=不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 企微侧怎么配
|
||||||
|
|
||||||
|
1. 插件 URL = HQ 页展示的 Base URL(全实例相同)
|
||||||
|
2. Header `X-Api-Key` = 该实例密钥
|
||||||
|
3. 第 2 步只添加 HQ 已勾选的工具(GET + Query);可用带 Key 的 `openapi.json` 对照路径
|
||||||
|
|
||||||
|
权限示例:运营勾全量;客服只勾 `order.read` / `user.read` / `store.read`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 验收
|
||||||
|
|
||||||
|
- [ ] HQ 建两个插件不同权限 → 同 URL 不同 Key → 分别能/不能调 `/orders`
|
||||||
|
- [ ] 无 Key / 错 Key → 401;无权限工具 → 403
|
||||||
|
- [ ] 带 Key 的 `openapi.json` 只含已授权 paths(无信封)
|
||||||
|
- [ ] `/metrics` 返回 `newStores`;`/stores` 含经营字段;审核/合伙人工具按权限可用
|
||||||
|
- [ ] 创建/轮换后明文 Key 只回显一次;列表为掩码
|
||||||
|
- [ ] 企微调试通过;日志 `plugin:{id}`
|
||||||
|
- [ ] 长连接 Bot / 消息推送 / 报告行为不变
|
||||||
+1
-1
@@ -48,7 +48,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
|||||||
|
|
||||||
**HQ 企微报告(v3.5.15)**:企微机器人下「报告」与「消息推送」分开。日报/周报/月报各配 Webhook 与发送时刻;走群机器人 markdown。账期截在发送日北京 0 点(前一天 24 点),不含发送当天:日报=昨日存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。
|
**HQ 企微报告(v3.5.15)**:企微机器人下「报告」与「消息推送」分开。日报/周报/月报各配 Webhook 与发送时刻;走群机器人 markdown。账期截在发送日北京 0 点(前一天 24 点),不含发送当天:日报=昨日存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。
|
||||||
|
|
||||||
**企微 API 插件(v3.5.16)**:`GET /api/v1/wecom/plugin/*`,Header `X-Api-Key`;只读订单/用户/门店/核销/推广码/经营指标。与长连接 Bot 独立。OpenAPI:`GET /api/v1/wecom/plugin/openapi.json`。
|
**企微 API 插件(v3.5.17)**:实例在 HQ「企微机器人 → API 插件」维护;共用 `GET /api/v1/wecom/plugin/*`,Header `X-Api-Key` 区分实例并按权限放行。经营指标含新增门店(`newStores`);门店搜索含经营数据;支持门店/信息/套餐审核对照只读查询;支持按合伙人查关联用户/门店/订单(`from`/`to` 时间筛选)。
|
||||||
|
|
||||||
**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
|
**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -150,7 +150,7 @@ HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑
|
|||||||
| 能力 | 入口 |
|
| 能力 | 入口 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 智能机器人 | `/wecom/bots` 长连接指令 |
|
| 智能机器人 | `/wecom/bots` 长连接指令 |
|
||||||
| API 插件 | `/api/v1/wecom/plugin` Header `X-Api-Key` 只读查询(与长连接独立) |
|
| API 插件 | HQ `/wecom/plugins` 多实例;`/api/v1/wecom/plugin` Header `X-Api-Key` 只读查询(与长连接独立) |
|
||||||
| 消息推送 | `/wecom/pushes` Webhook+eventKey |
|
| 消息推送 | `/wecom/pushes` Webhook+eventKey |
|
||||||
| 日志 | `/logs/wecom-bots` |
|
| 日志 | `/logs/wecom-bots` |
|
||||||
| C 端微信客服 | 系统设置 `CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`;小程序须已关联该企业微信客服 |
|
| C 端微信客服 | 系统设置 `CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`;小程序须已关联该企业微信客服 |
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { maskContactPhone } from './phone';
|
|||||||
import {
|
import {
|
||||||
clampWecomPluginPageSize,
|
clampWecomPluginPageSize,
|
||||||
isWecomPluginEnabled,
|
isWecomPluginEnabled,
|
||||||
|
matchWecomPluginByApiKey,
|
||||||
|
parseWecomPluginDateRange,
|
||||||
parseWecomPluginMetricsKind,
|
parseWecomPluginMetricsKind,
|
||||||
|
toWecomPluginMetricsView,
|
||||||
toWecomPluginUserView,
|
toWecomPluginUserView,
|
||||||
verifyWecomPluginApiKey,
|
verifyWecomPluginApiKey,
|
||||||
wecomPluginMetricsPeriod,
|
wecomPluginMetricsPeriod,
|
||||||
@@ -23,6 +26,17 @@ describe('verifyWecomPluginApiKey', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('matchWecomPluginByApiKey', () => {
|
||||||
|
it('returns the matching enabled instance', () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: '1', apiKey: 'alpha-key-aaaa' },
|
||||||
|
{ id: '2', apiKey: 'beta-key-bbbbb' },
|
||||||
|
];
|
||||||
|
expect(matchWecomPluginByApiKey('beta-key-bbbbb', rows)?.id).toBe('2');
|
||||||
|
expect(matchWecomPluginByApiKey('missing', rows)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isWecomPluginEnabled', () => {
|
describe('isWecomPluginEnabled', () => {
|
||||||
it('requires both the switch and a non-empty key', () => {
|
it('requires both the switch and a non-empty key', () => {
|
||||||
expect(isWecomPluginEnabled({ WECOM_PLUGIN_ENABLED: 'true', WECOM_PLUGIN_API_KEY: 'k' })).toBe(
|
expect(isWecomPluginEnabled({ WECOM_PLUGIN_ENABLED: 'true', WECOM_PLUGIN_API_KEY: 'k' })).toBe(
|
||||||
@@ -52,6 +66,35 @@ describe('clampWecomPluginPageSize / parseWecomPluginMetricsKind', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseWecomPluginDateRange / toWecomPluginMetricsView', () => {
|
||||||
|
it('parses YYYY-MM-DD range with inclusive end day', () => {
|
||||||
|
const range = parseWecomPluginDateRange('2026-09-01', '2026-09-03');
|
||||||
|
expect(range?.gte?.toISOString()).toBe(new Date('2026-09-01T00:00:00+08:00').toISOString());
|
||||||
|
expect(range?.lt?.toISOString()).toBe(new Date('2026-09-04T00:00:00+08:00').toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes newStores alias on metrics stats', () => {
|
||||||
|
const view = toWecomPluginMetricsView({
|
||||||
|
usersTotal: 1,
|
||||||
|
usersIncrement: 0,
|
||||||
|
partnersTotal: 1,
|
||||||
|
partnersIncrement: 0,
|
||||||
|
storesTotal: 10,
|
||||||
|
storesIncrement: 2,
|
||||||
|
ordersTotal: 0,
|
||||||
|
ordersIncrement: 0,
|
||||||
|
orderAmountTotal: 0,
|
||||||
|
orderAmountIncrement: 0,
|
||||||
|
redeemsTotal: 0,
|
||||||
|
redeemsIncrement: 0,
|
||||||
|
redeemAmountTotal: 0,
|
||||||
|
redeemAmountIncrement: 0,
|
||||||
|
});
|
||||||
|
expect(view.newStores).toBe(2);
|
||||||
|
expect(view.storesIncrement).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('toWecomPluginUserView', () => {
|
describe('toWecomPluginUserView', () => {
|
||||||
it('masks phone and fills empty nickname', () => {
|
it('masks phone and fills empty nickname', () => {
|
||||||
expect(toWecomPluginUserView({ userNo: 'DK1', nickname: null, phone: '13800138000' })).toEqual({
|
expect(toWecomPluginUserView({ userNo: 'DK1', nickname: null, phone: '13800138000' })).toEqual({
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { maskContactPhone } from './phone';
|
import { maskContactPhone } from './phone';
|
||||||
import { shanghaiYmd, startOfShanghaiDay } from './shanghai-date';
|
import { addShanghaiDays, shanghaiYmd, startOfShanghaiDay } from './shanghai-date';
|
||||||
import { wecomReportPeriod, type WecomReportKind, type WecomReportPeriod } from './wecom-report';
|
import {
|
||||||
|
wecomReportPeriod,
|
||||||
|
type WecomReportKind,
|
||||||
|
type WecomReportPeriod,
|
||||||
|
type WecomReportStats,
|
||||||
|
} from './wecom-report';
|
||||||
|
|
||||||
export const WECOM_PLUGIN_PAGE_SIZE_DEFAULT = 5;
|
export const WECOM_PLUGIN_PAGE_SIZE_DEFAULT = 5;
|
||||||
export const WECOM_PLUGIN_PAGE_SIZE_MAX = 10;
|
export const WECOM_PLUGIN_PAGE_SIZE_MAX = 10;
|
||||||
@@ -53,6 +58,20 @@ export function isWecomPluginEnabled(env: {
|
|||||||
return env.WECOM_PLUGIN_ENABLED === 'true' && Boolean(String(env.WECOM_PLUGIN_API_KEY ?? '').trim());
|
return env.WECOM_PLUGIN_ENABLED === 'true' && Boolean(String(env.WECOM_PLUGIN_API_KEY ?? '').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在少量启用实例中做常量时间匹配(长度不等仍会先失败) */
|
||||||
|
export function matchWecomPluginByApiKey<T extends { apiKey: string }>(
|
||||||
|
provided: string,
|
||||||
|
candidates: T[],
|
||||||
|
): T | null {
|
||||||
|
const got = String(provided ?? '').trim();
|
||||||
|
if (!got) return null;
|
||||||
|
let hit: T | null = null;
|
||||||
|
for (const row of candidates) {
|
||||||
|
if (verifyWecomPluginApiKey(got, row.apiKey)) hit = row;
|
||||||
|
}
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
export function wecomPluginMetricsPeriod(
|
export function wecomPluginMetricsPeriod(
|
||||||
kind: WecomPluginMetricsKind,
|
kind: WecomPluginMetricsKind,
|
||||||
now = new Date(),
|
now = new Date(),
|
||||||
@@ -74,6 +93,45 @@ export function wecomPluginMetricsPeriod(
|
|||||||
return { ...period, kind };
|
return { ...period, kind };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WecomPluginDateRange = { gte?: Date; lt?: Date };
|
||||||
|
|
||||||
|
/** 插件时间筛选:from/to 支持 YYYY-MM-DD 或 ISO;日期含当天全天 */
|
||||||
|
export function parseWecomPluginDateRange(
|
||||||
|
fromRaw?: string | null,
|
||||||
|
toRaw?: string | null,
|
||||||
|
): WecomPluginDateRange | undefined {
|
||||||
|
const from = parseWecomPluginDate(fromRaw);
|
||||||
|
const to = parseWecomPluginDate(toRaw);
|
||||||
|
if (!from && !to) return undefined;
|
||||||
|
const range: WecomPluginDateRange = {};
|
||||||
|
if (from) range.gte = from;
|
||||||
|
if (to) {
|
||||||
|
range.lt = isWecomPluginDateOnly(toRaw) ? addShanghaiDays(startOfShanghaiDay(to), 1) : to;
|
||||||
|
}
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWecomPluginDateOnly(raw?: string | null): boolean {
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/.test(String(raw ?? '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWecomPluginDate(raw?: string | null): Date | null {
|
||||||
|
const v = String(raw ?? '').trim();
|
||||||
|
if (!v) return null;
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) {
|
||||||
|
return startOfShanghaiDay(new Date(`${v}T00:00:00+08:00`));
|
||||||
|
}
|
||||||
|
const d = new Date(v);
|
||||||
|
return Number.isFinite(d.getTime()) ? d : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toWecomPluginMetricsView(stats: WecomReportStats) {
|
||||||
|
return {
|
||||||
|
...stats,
|
||||||
|
newStores: stats.storesIncrement,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function toWecomPluginUserView(user: {
|
export function toWecomPluginUserView(user: {
|
||||||
userNo: string;
|
userNo: string;
|
||||||
nickname?: string | null;
|
nickname?: string | null;
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
WECOM_PLUGIN_API_KEY_HEADER,
|
WECOM_PLUGIN_API_KEY_HEADER,
|
||||||
WECOM_PLUGIN_BASE_PATH,
|
WECOM_PLUGIN_BASE_PATH,
|
||||||
|
WECOM_PLUGIN_PATH_PERMISSION,
|
||||||
|
allowedWecomPluginOpenApiPaths,
|
||||||
|
maskWecomPluginApiKey,
|
||||||
|
parseWecomPluginPermissions,
|
||||||
resolveWecomPluginPublicUrl,
|
resolveWecomPluginPublicUrl,
|
||||||
|
wecomPluginHasPermission,
|
||||||
} from './wecom-plugin';
|
} from './wecom-plugin';
|
||||||
|
|
||||||
describe('resolveWecomPluginPublicUrl', () => {
|
describe('resolveWecomPluginPublicUrl', () => {
|
||||||
@@ -31,3 +36,44 @@ describe('resolveWecomPluginPublicUrl', () => {
|
|||||||
expect(WECOM_PLUGIN_API_KEY_HEADER).toBe('X-Api-Key');
|
expect(WECOM_PLUGIN_API_KEY_HEADER).toBe('X-Api-Key');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseWecomPluginPermissions', () => {
|
||||||
|
it('keeps catalog keys and drops unknown', () => {
|
||||||
|
expect(parseWecomPluginPermissions(['order.read', 'bogus', 'metrics.read'])).toEqual([
|
||||||
|
'order.read',
|
||||||
|
'metrics.read',
|
||||||
|
]);
|
||||||
|
expect(parseWecomPluginPermissions('["user.read","store.read"]')).toEqual([
|
||||||
|
'user.read',
|
||||||
|
'store.read',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('maskWecomPluginApiKey / path permission map', () => {
|
||||||
|
it('masks middle of the key', () => {
|
||||||
|
expect(maskWecomPluginApiKey('WAv2xxxxYb9')).toBe('WAv2••••xYb9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps openapi paths to tool permissions', () => {
|
||||||
|
expect(WECOM_PLUGIN_PATH_PERMISSION['/orders']).toBe('order.read');
|
||||||
|
expect(WECOM_PLUGIN_PATH_PERMISSION['/promo-codes/{code}/stats']).toBe('promo.read');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters openapi paths by instance permissions', () => {
|
||||||
|
expect(allowedWecomPluginOpenApiPaths(['order.read', 'user.read'])).toEqual([
|
||||||
|
'/orders',
|
||||||
|
'/users',
|
||||||
|
]);
|
||||||
|
expect(allowedWecomPluginOpenApiPaths(['store.audit.read'])).toEqual([
|
||||||
|
'/store-audits',
|
||||||
|
'/store-info-audits',
|
||||||
|
'/store-info-audits/{id}',
|
||||||
|
'/store-package-audits',
|
||||||
|
'/store-package-audits/{id}',
|
||||||
|
]);
|
||||||
|
expect(allowedWecomPluginOpenApiPaths(['partner.read'])).toContain('/partners');
|
||||||
|
expect(wecomPluginHasPermission(['order.read'], 'order.read')).toBe(true);
|
||||||
|
expect(wecomPluginHasPermission(['order.read'], 'metrics.read')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,188 @@ export const WECOM_PLUGIN_METRICS_KINDS = ['today', 'daily', 'weekly', 'monthly'
|
|||||||
|
|
||||||
export type WecomPluginMetricsKind = (typeof WECOM_PLUGIN_METRICS_KINDS)[number];
|
export type WecomPluginMetricsKind = (typeof WECOM_PLUGIN_METRICS_KINDS)[number];
|
||||||
|
|
||||||
|
export const WECOM_PLUGIN_PERMISSIONS = [
|
||||||
|
'order.read',
|
||||||
|
'user.read',
|
||||||
|
'store.read',
|
||||||
|
'redeem.read',
|
||||||
|
'promo.read',
|
||||||
|
'metrics.read',
|
||||||
|
'store.audit.read',
|
||||||
|
'partner.read',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type WecomPluginPermission = (typeof WECOM_PLUGIN_PERMISSIONS)[number];
|
||||||
|
|
||||||
|
export const WECOM_PLUGIN_PERMISSION_LABELS: Record<WecomPluginPermission, string> = {
|
||||||
|
'order.read': '查询订单',
|
||||||
|
'user.read': '查询用户',
|
||||||
|
'store.read': '查询门店',
|
||||||
|
'redeem.read': '查询核销',
|
||||||
|
'promo.read': '查询推广码',
|
||||||
|
'metrics.read': '经营指标',
|
||||||
|
'store.audit.read': '门店/套餐审核',
|
||||||
|
'partner.read': '合伙人关联查询',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WECOM_PLUGIN_PERMISSION_GROUPS: Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
permissions: WecomPluginPermission[];
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: 'query',
|
||||||
|
label: '只读查询',
|
||||||
|
permissions: [
|
||||||
|
'order.read',
|
||||||
|
'user.read',
|
||||||
|
'store.read',
|
||||||
|
'redeem.read',
|
||||||
|
'promo.read',
|
||||||
|
'metrics.read',
|
||||||
|
'partner.read',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audit',
|
||||||
|
label: '审核对照',
|
||||||
|
permissions: ['store.audit.read'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** OpenAPI path → 工具权限 */
|
||||||
|
export const WECOM_PLUGIN_PATH_PERMISSION: Record<string, WecomPluginPermission> = {
|
||||||
|
'/orders': 'order.read',
|
||||||
|
'/users': 'user.read',
|
||||||
|
'/stores': 'store.read',
|
||||||
|
'/redeems': 'redeem.read',
|
||||||
|
'/promo-codes': 'promo.read',
|
||||||
|
'/promo-codes/{code}/stats': 'promo.read',
|
||||||
|
'/metrics': 'metrics.read',
|
||||||
|
'/store-audits': 'store.audit.read',
|
||||||
|
'/store-info-audits': 'store.audit.read',
|
||||||
|
'/store-info-audits/{id}': 'store.audit.read',
|
||||||
|
'/store-package-audits': 'store.audit.read',
|
||||||
|
'/store-package-audits/{id}': 'store.audit.read',
|
||||||
|
'/partners': 'partner.read',
|
||||||
|
'/partners/{partnerId}/users': 'partner.read',
|
||||||
|
'/partners/{partnerId}/stores': 'partner.read',
|
||||||
|
'/partners/{partnerId}/orders': 'partner.read',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WECOM_PLUGIN_TOOL_PATHS: Record<WecomPluginPermission, string[]> = {
|
||||||
|
'order.read': ['/orders'],
|
||||||
|
'user.read': ['/users'],
|
||||||
|
'store.read': ['/stores'],
|
||||||
|
'redeem.read': ['/redeems'],
|
||||||
|
'promo.read': ['/promo-codes', '/promo-codes/{code}/stats'],
|
||||||
|
'metrics.read': ['/metrics'],
|
||||||
|
'store.audit.read': [
|
||||||
|
'/store-audits',
|
||||||
|
'/store-info-audits',
|
||||||
|
'/store-info-audits/{id}',
|
||||||
|
'/store-package-audits',
|
||||||
|
'/store-package-audits/{id}',
|
||||||
|
],
|
||||||
|
'partner.read': [
|
||||||
|
'/partners',
|
||||||
|
'/partners/{partnerId}/users',
|
||||||
|
'/partners/{partnerId}/stores',
|
||||||
|
'/partners/{partnerId}/orders',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseWecomPluginPermissions(
|
||||||
|
raw?: string | string[] | null,
|
||||||
|
): WecomPluginPermission[] {
|
||||||
|
const valid = new Set<string>(WECOM_PLUGIN_PERMISSIONS);
|
||||||
|
let list: string[];
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
list = raw.map(String);
|
||||||
|
} else {
|
||||||
|
const text = String(raw ?? '').trim();
|
||||||
|
if (!text) {
|
||||||
|
list = [];
|
||||||
|
} else if (text.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(text);
|
||||||
|
list = Array.isArray(parsed) ? parsed.map(String) : [];
|
||||||
|
} catch {
|
||||||
|
list = text.split(/[,,\s]+/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
list = text.split(/[,,\s]+/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(list.filter((s): s is WecomPluginPermission => valid.has(s)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wecomPluginHasPermission(
|
||||||
|
permissions: WecomPluginPermission[],
|
||||||
|
permission: WecomPluginPermission,
|
||||||
|
): boolean {
|
||||||
|
return permissions.includes(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前实例可暴露给企微第 2 步的 OpenAPI paths */
|
||||||
|
export function allowedWecomPluginOpenApiPaths(
|
||||||
|
permissions: WecomPluginPermission[],
|
||||||
|
): string[] {
|
||||||
|
const allowed = new Set(permissions);
|
||||||
|
return Object.entries(WECOM_PLUGIN_PATH_PERMISSION)
|
||||||
|
.filter(([, perm]) => allowed.has(perm))
|
||||||
|
.map(([path]) => path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskWecomPluginApiKey(apiKey?: string | null): string {
|
||||||
|
const v = String(apiKey ?? '').trim();
|
||||||
|
if (!v) return '未配置';
|
||||||
|
if (v.length <= 8) return '••••';
|
||||||
|
return `${v.slice(0, 4)}••••${v.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WecomApiPluginDto = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
apiKeyConfigured: boolean;
|
||||||
|
apiKeyMasked: string;
|
||||||
|
permissions: WecomPluginPermission[];
|
||||||
|
remark: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateWecomApiPluginRequest = {
|
||||||
|
name: string;
|
||||||
|
apiKey?: string;
|
||||||
|
permissions: WecomPluginPermission[];
|
||||||
|
remark?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
sortOrder?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateWecomApiPluginRequest = {
|
||||||
|
name?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
permissions?: WecomPluginPermission[];
|
||||||
|
remark?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
sortOrder?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WecomApiPluginSecretDto = WecomApiPluginDto & {
|
||||||
|
apiKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WecomApiPluginListDto = {
|
||||||
|
items: WecomApiPluginDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
/** 按 HQ 当前域名推断插件公网 Base URL(不含密钥) */
|
/** 按 HQ 当前域名推断插件公网 Base URL(不含密钥) */
|
||||||
export function resolveWecomPluginPublicUrl(hostname: string): string {
|
export function resolveWecomPluginPublicUrl(hostname: string): string {
|
||||||
const host = String(hostname || '').toLowerCase();
|
const host = String(hostname || '').toLowerCase();
|
||||||
|
|||||||
@@ -67,10 +67,6 @@ WX_MINI_MSG_AES_KEY=
|
|||||||
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
|
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
|
||||||
WECOM_AIBOT_ENABLED=false
|
WECOM_AIBOT_ENABLED=false
|
||||||
|
|
||||||
# 企微「API 插件」只读数据面(与长连接 Bot 独立;密钥勿提交)
|
|
||||||
WECOM_PLUGIN_ENABLED=false
|
|
||||||
# WECOM_PLUGIN_API_KEY=
|
|
||||||
|
|
||||||
# 运营告警 Webhook(已废弃运行时读取,仅 seed 一次性导入到 HQ「消息推送」)
|
# 运营告警 Webhook(已废弃运行时读取,仅 seed 一次性导入到 HQ「消息推送」)
|
||||||
# 配置后执行 pnpm prisma:seed-wecom-push 或 API 启动时自动 ensureDefaults
|
# 配置后执行 pnpm prisma:seed-wecom-push 或 API 启动时自动 ensureDefaults
|
||||||
# WECOM_ALERT_ENABLED=false
|
# WECOM_ALERT_ENABLED=false
|
||||||
|
|||||||
@@ -54,10 +54,6 @@ WX_MINI_MSG_AES_KEY=
|
|||||||
# 企业微信机器人总开关(实例在 HQ 企微机器人模块维护)
|
# 企业微信机器人总开关(实例在 HQ 企微机器人模块维护)
|
||||||
WECOM_AIBOT_ENABLED=false
|
WECOM_AIBOT_ENABLED=false
|
||||||
|
|
||||||
# 企微 API 插件只读数据面(与长连接 Bot 独立)
|
|
||||||
WECOM_PLUGIN_ENABLED=false
|
|
||||||
# WECOM_PLUGIN_API_KEY=
|
|
||||||
|
|
||||||
# 运营告警:企业微信群机器人 Webhook
|
# 运营告警:企业微信群机器人 Webhook
|
||||||
# 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」)
|
# 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」)
|
||||||
# WECOM_ALERT_ENABLED=false
|
# WECOM_ALERT_ENABLED=false
|
||||||
|
|||||||
@@ -54,10 +54,6 @@ WX_MINI_MSG_AES_KEY=
|
|||||||
|
|
||||||
WECOM_AIBOT_ENABLED=false
|
WECOM_AIBOT_ENABLED=false
|
||||||
|
|
||||||
# 企微 API 插件(测试环境单独一把 Key)
|
|
||||||
WECOM_PLUGIN_ENABLED=false
|
|
||||||
# WECOM_PLUGIN_API_KEY=
|
|
||||||
|
|
||||||
# 运营告警:企业微信群机器人 Webhook
|
# 运营告警:企业微信群机器人 Webhook
|
||||||
# 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」)
|
# 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」)
|
||||||
# WECOM_ALERT_ENABLED=false
|
# WECOM_ALERT_ENABLED=false
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"prisma:seed-wecom-push": "ts-node --transpile-only prisma/seed-wecom-message-push.ts",
|
"prisma:seed-wecom-push": "ts-node --transpile-only prisma/seed-wecom-message-push.ts",
|
||||||
"prisma:migrate-wecom-push": "ts-node --transpile-only prisma/migrate-wecom-message-push.ts",
|
"prisma:migrate-wecom-push": "ts-node --transpile-only prisma/migrate-wecom-message-push.ts",
|
||||||
"prisma:migrate-wecom-report": "ts-node --transpile-only prisma/migrate-wecom-report.ts",
|
"prisma:migrate-wecom-report": "ts-node --transpile-only prisma/migrate-wecom-report.ts",
|
||||||
|
"prisma:migrate-wecom-api-plugin": "ts-node --transpile-only prisma/migrate-wecom-api-plugin.ts",
|
||||||
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
|
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
|
||||||
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts",
|
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts",
|
||||||
"prisma:merge-spu-dk000007-008": "ts-node --transpile-only prisma/merge-spu-dk000007-008.ts",
|
"prisma:merge-spu-dk000007-008": "ts-node --transpile-only prisma/merge-spu-dk000007-008.ts",
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 创建 wecom_api_plugin(若不存在)。
|
||||||
|
* 不依赖 Prisma Client 新 model,可在 generate 之前执行。
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS wecom_api_plugin (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
api_key VARCHAR(128) NOT NULL,
|
||||||
|
permissions TEXT NOT NULL,
|
||||||
|
remark VARCHAR(256) NULL,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
UNIQUE KEY wecom_api_plugin_api_key_key (api_key),
|
||||||
|
KEY wecom_api_plugin_enabled_sort (enabled, sort_order)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
console.log('migrate-wecom-api-plugin done');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -493,6 +493,23 @@ model WecomBot {
|
|||||||
@@map("wecom_bot")
|
@@map("wecom_bot")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 企微智能机器人 API 插件实例(v3.5.17 · 多 Key + 工具权限)
|
||||||
|
model WecomApiPlugin {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
name String @db.VarChar(64)
|
||||||
|
apiKey String @unique @map("api_key") @db.VarChar(128)
|
||||||
|
/// JSON 字符串数组,工具权限
|
||||||
|
permissions String @db.Text
|
||||||
|
remark String? @db.VarChar(256)
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
@@index([enabled, sortOrder])
|
||||||
|
@@map("wecom_api_plugin")
|
||||||
|
}
|
||||||
|
|
||||||
/// 企微机器人操作审计
|
/// 企微机器人操作审计
|
||||||
model LogWecomBot {
|
model LogWecomBot {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ const fixed = {
|
|||||||
WX_PAY_NOTIFY_URL: 'https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay',
|
WX_PAY_NOTIFY_URL: 'https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay',
|
||||||
OSS_UPLOAD_PREFIX: 'staging/uploads',
|
OSS_UPLOAD_PREFIX: 'staging/uploads',
|
||||||
WECOM_AIBOT_ENABLED: 'false',
|
WECOM_AIBOT_ENABLED: 'false',
|
||||||
WECOM_PLUGIN_ENABLED: 'false',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const preferFromProd = [
|
const preferFromProd = [
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ export const HqOperationAction = {
|
|||||||
WECOM_MESSAGE_PUSH_UPDATE: 'WECOM_MESSAGE_PUSH_UPDATE',
|
WECOM_MESSAGE_PUSH_UPDATE: 'WECOM_MESSAGE_PUSH_UPDATE',
|
||||||
WECOM_MESSAGE_PUSH_DELETE: 'WECOM_MESSAGE_PUSH_DELETE',
|
WECOM_MESSAGE_PUSH_DELETE: 'WECOM_MESSAGE_PUSH_DELETE',
|
||||||
WECOM_MESSAGE_PUSH_TEST: 'WECOM_MESSAGE_PUSH_TEST',
|
WECOM_MESSAGE_PUSH_TEST: 'WECOM_MESSAGE_PUSH_TEST',
|
||||||
|
WECOM_API_PLUGIN_CREATE: 'WECOM_API_PLUGIN_CREATE',
|
||||||
|
WECOM_API_PLUGIN_UPDATE: 'WECOM_API_PLUGIN_UPDATE',
|
||||||
|
WECOM_API_PLUGIN_DELETE: 'WECOM_API_PLUGIN_DELETE',
|
||||||
|
WECOM_API_PLUGIN_ROTATE_KEY: 'WECOM_API_PLUGIN_ROTATE_KEY',
|
||||||
WECOM_REPORT_UPDATE: 'WECOM_REPORT_UPDATE',
|
WECOM_REPORT_UPDATE: 'WECOM_REPORT_UPDATE',
|
||||||
WECOM_REPORT_SEND: 'WECOM_REPORT_SEND',
|
WECOM_REPORT_SEND: 'WECOM_REPORT_SEND',
|
||||||
LLM_CONFIG_CREATE: 'LLM_CONFIG_CREATE',
|
LLM_CONFIG_CREATE: 'LLM_CONFIG_CREATE',
|
||||||
@@ -232,6 +236,10 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.WECOM_MESSAGE_PUSH_UPDATE]: '编辑企微消息推送',
|
[HqOperationAction.WECOM_MESSAGE_PUSH_UPDATE]: '编辑企微消息推送',
|
||||||
[HqOperationAction.WECOM_MESSAGE_PUSH_DELETE]: '删除企微消息推送',
|
[HqOperationAction.WECOM_MESSAGE_PUSH_DELETE]: '删除企微消息推送',
|
||||||
[HqOperationAction.WECOM_MESSAGE_PUSH_TEST]: '测试企微消息推送',
|
[HqOperationAction.WECOM_MESSAGE_PUSH_TEST]: '测试企微消息推送',
|
||||||
|
[HqOperationAction.WECOM_API_PLUGIN_CREATE]: '创建企微 API 插件',
|
||||||
|
[HqOperationAction.WECOM_API_PLUGIN_UPDATE]: '编辑企微 API 插件',
|
||||||
|
[HqOperationAction.WECOM_API_PLUGIN_DELETE]: '删除企微 API 插件',
|
||||||
|
[HqOperationAction.WECOM_API_PLUGIN_ROTATE_KEY]: '轮换企微 API 插件密钥',
|
||||||
[HqOperationAction.WECOM_REPORT_UPDATE]: '编辑企微经营报告',
|
[HqOperationAction.WECOM_REPORT_UPDATE]: '编辑企微经营报告',
|
||||||
[HqOperationAction.WECOM_REPORT_SEND]: '发送企微经营报告',
|
[HqOperationAction.WECOM_REPORT_SEND]: '发送企微经营报告',
|
||||||
[HqOperationAction.LLM_CONFIG_CREATE]: '创建语言模型配置',
|
[HqOperationAction.LLM_CONFIG_CREATE]: '创建语言模型配置',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type WecomBotAuditContext = {
|
|||||||
bot: WecomBotRuntimeConfig;
|
bot: WecomBotRuntimeConfig;
|
||||||
wecomUserId: string;
|
wecomUserId: string;
|
||||||
action: string;
|
action: string;
|
||||||
permission?: WecomBotPermission | null;
|
permission?: WecomBotPermission | string | null;
|
||||||
inputSummary?: string | null;
|
inputSummary?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
WECOM_PLUGIN_PERMISSIONS,
|
||||||
|
parseWecomPluginPermissions,
|
||||||
|
type WecomPluginPermission,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { isWecomPluginEnabled, matchWecomPluginByApiKey } from '@dukang/domain';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import type { WecomPluginRuntime } from './wecom-plugin.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WecomPluginAuthService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(WecomPluginAuthService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.migrateFromEnv().catch((e) => {
|
||||||
|
this.logger.warn(
|
||||||
|
`wecom plugin env migrate skipped: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveByApiKey(provided: string): Promise<WecomPluginRuntime | null> {
|
||||||
|
const key = String(provided ?? '').trim();
|
||||||
|
if (!key) return null;
|
||||||
|
const rows = await this.prisma.wecomApiPlugin.findMany({
|
||||||
|
where: { enabled: true },
|
||||||
|
select: { id: true, name: true, apiKey: true, permissions: true },
|
||||||
|
});
|
||||||
|
const hit = matchWecomPluginByApiKey(key, rows);
|
||||||
|
if (!hit) return null;
|
||||||
|
return {
|
||||||
|
id: hit.id.toString(),
|
||||||
|
name: hit.name,
|
||||||
|
permissions: parseWecomPluginPermissions(hit.permissions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async migrateFromEnv() {
|
||||||
|
if (!isWecomPluginEnabled(process.env)) return;
|
||||||
|
const count = await this.prisma.wecomApiPlugin.count();
|
||||||
|
if (count > 0) return;
|
||||||
|
const apiKey = String(process.env.WECOM_PLUGIN_API_KEY ?? '').trim();
|
||||||
|
if (!apiKey) return;
|
||||||
|
await this.prisma.wecomApiPlugin.create({
|
||||||
|
data: {
|
||||||
|
name: '迁移自 env',
|
||||||
|
apiKey,
|
||||||
|
permissions: JSON.stringify([...WECOM_PLUGIN_PERMISSIONS] as WecomPluginPermission[]),
|
||||||
|
remark: '由 WECOM_PLUGIN_API_KEY 一次性导入',
|
||||||
|
enabled: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log('wecom api plugin migrated from env');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,29 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
clampWecomPluginPage,
|
clampWecomPluginPage,
|
||||||
clampWecomPluginPageSize,
|
clampWecomPluginPageSize,
|
||||||
maskContactPhone,
|
maskContactPhone,
|
||||||
MOBILE_PHONE_RE,
|
MOBILE_PHONE_RE,
|
||||||
parseWecomPluginMetricsKind,
|
parseWecomPluginMetricsKind,
|
||||||
|
parseWecomPluginDateRange,
|
||||||
|
toWecomPluginMetricsView,
|
||||||
toWecomPluginUserView,
|
toWecomPluginUserView,
|
||||||
wecomPluginMetricsPeriod,
|
wecomPluginMetricsPeriod,
|
||||||
type WecomReportStats,
|
type WecomReportStats,
|
||||||
} from '@dukang/domain';
|
} from '@dukang/domain';
|
||||||
|
import {
|
||||||
|
WECOM_PLUGIN_TOOL_PATHS,
|
||||||
|
wecomPluginHasPermission,
|
||||||
|
STORE_INFO_CHANGEABLE_FIELD_LABELS,
|
||||||
|
type StoreInfoChangeableField,
|
||||||
|
type WecomPluginPermission,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { PromoCodeService } from '../../modules/promo/promo-code.service';
|
import { PromoCodeService } from '../../modules/promo/promo-code.service';
|
||||||
import { WecomBotAuditService } from './wecom-bot-audit.service';
|
import { WecomBotAuditService } from './wecom-bot-audit.service';
|
||||||
import { WECOM_PLUGIN_AUDIT_BOT } from './wecom-plugin.constants';
|
import { wecomPluginAuditBot, type WecomPluginRuntime } from './wecom-plugin.types';
|
||||||
|
import { filterWecomPluginOpenApi, WECOM_PLUGIN_OPENAPI } from './wecom-plugin.openapi';
|
||||||
|
|
||||||
function asNumber(v: Prisma.Decimal | number | null | undefined): number {
|
function asNumber(v: Prisma.Decimal | number | null | undefined): number {
|
||||||
if (v == null) return 0;
|
if (v == null) return 0;
|
||||||
@@ -35,22 +45,40 @@ export class WecomPluginQueryService {
|
|||||||
private readonly audit: WecomBotAuditService,
|
private readonly audit: WecomBotAuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
info() {
|
info(plugin: WecomPluginRuntime) {
|
||||||
return {
|
return {
|
||||||
name: '杜康好客运营查询',
|
name: plugin.name,
|
||||||
description: '企微智能机器人只读 API 插件。查询订单、用户、门店、核销、推广码与经营指标。',
|
description: '企微智能机器人只读 API 插件。查询订单、用户、门店经营、核销、推广码、经营指标、审核对照与合伙人关联数据。',
|
||||||
auth: { header: 'X-Api-Key' },
|
auth: { header: 'X-Api-Key' },
|
||||||
tools: ['orders', 'users', 'stores', 'redeems', 'promo-codes', 'metrics'],
|
permissions: plugin.permissions,
|
||||||
|
tools: plugin.permissions.flatMap((p) => WECOM_PLUGIN_TOOL_PATHS[p]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
queryOrders(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
|
openapi(plugin: WecomPluginRuntime) {
|
||||||
|
return filterWecomPluginOpenApi(WECOM_PLUGIN_OPENAPI, plugin.permissions, plugin.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private requirePerm(plugin: WecomPluginRuntime, permission: WecomPluginPermission) {
|
||||||
|
if (!wecomPluginHasPermission(plugin.permissions, permission)) {
|
||||||
|
throw new ForbiddenException(`当前插件无权限:${permission}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queryOrders(
|
||||||
|
plugin: WecomPluginRuntime,
|
||||||
|
wecomUserId: string,
|
||||||
|
q?: string,
|
||||||
|
page?: string,
|
||||||
|
pageSize?: string,
|
||||||
|
) {
|
||||||
|
this.requirePerm(plugin, 'order.read');
|
||||||
const keyword = requireQuery(q);
|
const keyword = requireQuery(q);
|
||||||
const take = clampWecomPluginPageSize(pageSize);
|
const take = clampWecomPluginPageSize(pageSize);
|
||||||
const skip = (clampWecomPluginPage(page) - 1) * take;
|
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||||
return this.audit.run(
|
return this.audit.run(
|
||||||
{
|
{
|
||||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
bot: wecomPluginAuditBot(plugin),
|
||||||
wecomUserId,
|
wecomUserId,
|
||||||
action: 'plugin.order.read',
|
action: 'plugin.order.read',
|
||||||
permission: 'order.read',
|
permission: 'order.read',
|
||||||
@@ -96,13 +124,20 @@ export class WecomPluginQueryService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
queryUsers(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
|
queryUsers(
|
||||||
|
plugin: WecomPluginRuntime,
|
||||||
|
wecomUserId: string,
|
||||||
|
q?: string,
|
||||||
|
page?: string,
|
||||||
|
pageSize?: string,
|
||||||
|
) {
|
||||||
|
this.requirePerm(plugin, 'user.read');
|
||||||
const keyword = requireQuery(q);
|
const keyword = requireQuery(q);
|
||||||
const take = clampWecomPluginPageSize(pageSize);
|
const take = clampWecomPluginPageSize(pageSize);
|
||||||
const skip = (clampWecomPluginPage(page) - 1) * take;
|
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||||
return this.audit.run(
|
return this.audit.run(
|
||||||
{
|
{
|
||||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
bot: wecomPluginAuditBot(plugin),
|
||||||
wecomUserId,
|
wecomUserId,
|
||||||
action: 'plugin.user.read',
|
action: 'plugin.user.read',
|
||||||
permission: 'user.read',
|
permission: 'user.read',
|
||||||
@@ -152,13 +187,20 @@ export class WecomPluginQueryService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
queryStores(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
|
queryStores(
|
||||||
|
plugin: WecomPluginRuntime,
|
||||||
|
wecomUserId: string,
|
||||||
|
q?: string,
|
||||||
|
page?: string,
|
||||||
|
pageSize?: string,
|
||||||
|
) {
|
||||||
|
this.requirePerm(plugin, 'store.read');
|
||||||
const keyword = requireQuery(q);
|
const keyword = requireQuery(q);
|
||||||
const take = clampWecomPluginPageSize(pageSize);
|
const take = clampWecomPluginPageSize(pageSize);
|
||||||
const skip = (clampWecomPluginPage(page) - 1) * take;
|
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||||
return this.audit.run(
|
return this.audit.run(
|
||||||
{
|
{
|
||||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
bot: wecomPluginAuditBot(plugin),
|
||||||
wecomUserId,
|
wecomUserId,
|
||||||
action: 'plugin.store.read',
|
action: 'plugin.store.read',
|
||||||
permission: 'store.read',
|
permission: 'store.read',
|
||||||
@@ -173,39 +215,71 @@ export class WecomPluginQueryService {
|
|||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
select: {
|
select: {
|
||||||
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
auditStatus: true,
|
||||||
cityName: true,
|
cityName: true,
|
||||||
district: true,
|
district: true,
|
||||||
address: true,
|
address: true,
|
||||||
contactPhone: true,
|
contactPhone: true,
|
||||||
phone: true,
|
phone: true,
|
||||||
|
rating: true,
|
||||||
|
createdAt: true,
|
||||||
|
partnerAccount: { select: { id: true, name: true, companyName: true } },
|
||||||
|
_count: { select: { redeemRecords: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.store.count({ where }),
|
this.prisma.store.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
const redeemSums =
|
||||||
|
rows.length > 0
|
||||||
|
? await this.prisma.redeemRecord.groupBy({
|
||||||
|
by: ['storeId'],
|
||||||
|
where: { storeId: { in: rows.map((s) => s.id) } },
|
||||||
|
_sum: { amount: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const redeemedByStore = new Map(
|
||||||
|
redeemSums.map((r) => [r.storeId.toString(), asNumber(r._sum.amount)]),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
total,
|
total,
|
||||||
items: rows.map((s) => ({
|
items: rows.map((s) => ({
|
||||||
|
id: s.id.toString(),
|
||||||
name: s.name,
|
name: s.name,
|
||||||
status: s.status,
|
status: s.status,
|
||||||
|
auditStatus: s.auditStatus,
|
||||||
cityName: s.cityName,
|
cityName: s.cityName,
|
||||||
district: s.district,
|
district: s.district,
|
||||||
address: s.address,
|
address: s.address,
|
||||||
contactPhone: maskContactPhone(s.contactPhone || s.phone),
|
contactPhone: maskContactPhone(s.contactPhone || s.phone),
|
||||||
|
rating: s.rating != null ? asNumber(s.rating) : null,
|
||||||
|
redeemCount: s._count.redeemRecords,
|
||||||
|
totalRedeemedBenefitAmount: redeemedByStore.get(s.id.toString()) ?? 0,
|
||||||
|
partnerName: s.partnerAccount.companyName || s.partnerAccount.name,
|
||||||
|
partnerId: s.partnerAccount.id.toString(),
|
||||||
|
createdAt: s.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
queryRedeems(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
|
queryRedeems(
|
||||||
|
plugin: WecomPluginRuntime,
|
||||||
|
wecomUserId: string,
|
||||||
|
q?: string,
|
||||||
|
page?: string,
|
||||||
|
pageSize?: string,
|
||||||
|
) {
|
||||||
|
this.requirePerm(plugin, 'redeem.read');
|
||||||
const keyword = requireQuery(q);
|
const keyword = requireQuery(q);
|
||||||
const take = clampWecomPluginPageSize(pageSize);
|
const take = clampWecomPluginPageSize(pageSize);
|
||||||
const skip = (clampWecomPluginPage(page) - 1) * take;
|
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||||
return this.audit.run(
|
return this.audit.run(
|
||||||
{
|
{
|
||||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
bot: wecomPluginAuditBot(plugin),
|
||||||
wecomUserId,
|
wecomUserId,
|
||||||
action: 'plugin.redeem.read',
|
action: 'plugin.redeem.read',
|
||||||
permission: 'redeem.read',
|
permission: 'redeem.read',
|
||||||
@@ -249,15 +323,23 @@ export class WecomPluginQueryService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
queryPromoCodes(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
|
queryPromoCodes(
|
||||||
|
plugin: WecomPluginRuntime,
|
||||||
|
wecomUserId: string,
|
||||||
|
q?: string,
|
||||||
|
page?: string,
|
||||||
|
pageSize?: string,
|
||||||
|
) {
|
||||||
|
this.requirePerm(plugin, 'promo.read');
|
||||||
const keyword = requireQuery(q);
|
const keyword = requireQuery(q);
|
||||||
const take = clampWecomPluginPageSize(pageSize);
|
const take = clampWecomPluginPageSize(pageSize);
|
||||||
const skip = (clampWecomPluginPage(page) - 1) * take;
|
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||||
return this.audit.run(
|
return this.audit.run(
|
||||||
{
|
{
|
||||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
bot: wecomPluginAuditBot(plugin),
|
||||||
wecomUserId,
|
wecomUserId,
|
||||||
action: 'plugin.promo.read',
|
action: 'plugin.promo.read',
|
||||||
|
permission: 'promo.read',
|
||||||
inputSummary: keyword,
|
inputSummary: keyword,
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
@@ -289,13 +371,15 @@ export class WecomPluginQueryService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
queryPromoCodeStats(wecomUserId: string, code?: string) {
|
queryPromoCodeStats(plugin: WecomPluginRuntime, wecomUserId: string, code?: string) {
|
||||||
|
this.requirePerm(plugin, 'promo.read');
|
||||||
const keyword = requireQuery(code);
|
const keyword = requireQuery(code);
|
||||||
return this.audit.run(
|
return this.audit.run(
|
||||||
{
|
{
|
||||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
bot: wecomPluginAuditBot(plugin),
|
||||||
wecomUserId,
|
wecomUserId,
|
||||||
action: 'plugin.promo.stats',
|
action: 'plugin.promo.stats',
|
||||||
|
permission: 'promo.read',
|
||||||
inputSummary: keyword,
|
inputSummary: keyword,
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
@@ -315,16 +399,18 @@ export class WecomPluginQueryService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
queryMetrics(wecomUserId: string, kindRaw?: string) {
|
queryMetrics(plugin: WecomPluginRuntime, wecomUserId: string, kindRaw?: string) {
|
||||||
|
this.requirePerm(plugin, 'metrics.read');
|
||||||
const kind = parseWecomPluginMetricsKind(kindRaw);
|
const kind = parseWecomPluginMetricsKind(kindRaw);
|
||||||
if (!kind) {
|
if (!kind) {
|
||||||
throw new BadRequestException('kind 须为 today | daily | weekly | monthly');
|
throw new BadRequestException('kind 须为 today | daily | weekly | monthly');
|
||||||
}
|
}
|
||||||
return this.audit.run(
|
return this.audit.run(
|
||||||
{
|
{
|
||||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
bot: wecomPluginAuditBot(plugin),
|
||||||
wecomUserId,
|
wecomUserId,
|
||||||
action: 'plugin.metrics.read',
|
action: 'plugin.metrics.read',
|
||||||
|
permission: 'metrics.read',
|
||||||
inputSummary: kind,
|
inputSummary: kind,
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
@@ -336,7 +422,7 @@ export class WecomPluginQueryService {
|
|||||||
rangeLabel: period.rangeLabel,
|
rangeLabel: period.rangeLabel,
|
||||||
incrementLabel: period.incrementLabel,
|
incrementLabel: period.incrementLabel,
|
||||||
periodKey: period.periodKey,
|
periodKey: period.periodKey,
|
||||||
stats,
|
stats: toWecomPluginMetricsView(stats),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -415,4 +501,555 @@ export class WecomPluginQueryService {
|
|||||||
redeemAmountIncrement: asNumber(redeemAmountIncrement._sum.amount),
|
redeemAmountIncrement: asNumber(redeemAmountIncrement._sum.amount),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
queryStoreAudits(
|
||||||
|
plugin: WecomPluginRuntime,
|
||||||
|
wecomUserId: string,
|
||||||
|
q?: string,
|
||||||
|
status?: string,
|
||||||
|
page?: string,
|
||||||
|
pageSize?: string,
|
||||||
|
) {
|
||||||
|
this.requirePerm(plugin, 'store.audit.read');
|
||||||
|
const take = clampWecomPluginPageSize(pageSize);
|
||||||
|
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||||
|
const auditStatus = (status?.trim() || 'PENDING') as 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||||
|
const where: Prisma.StoreWhereInput = { auditStatus };
|
||||||
|
const keyword = String(q ?? '').trim();
|
||||||
|
if (keyword) where.name = { contains: keyword };
|
||||||
|
return this.audit.run(
|
||||||
|
{
|
||||||
|
bot: wecomPluginAuditBot(plugin),
|
||||||
|
wecomUserId,
|
||||||
|
action: 'plugin.store.audit.list',
|
||||||
|
permission: 'store.audit.read',
|
||||||
|
inputSummary: keyword || auditStatus,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
this.prisma.store.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
auditStatus: true,
|
||||||
|
rejectReason: true,
|
||||||
|
cityName: true,
|
||||||
|
district: true,
|
||||||
|
address: true,
|
||||||
|
contactPhone: true,
|
||||||
|
phone: true,
|
||||||
|
createdAt: true,
|
||||||
|
partnerAccount: { select: { id: true, name: true, companyName: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.store.count({ where }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
items: rows.map((s) => ({
|
||||||
|
id: s.id.toString(),
|
||||||
|
name: s.name,
|
||||||
|
status: s.status,
|
||||||
|
auditStatus: s.auditStatus,
|
||||||
|
rejectReason: s.rejectReason,
|
||||||
|
cityName: s.cityName,
|
||||||
|
district: s.district,
|
||||||
|
address: s.address,
|
||||||
|
contactPhone: maskContactPhone(s.contactPhone || s.phone),
|
||||||
|
partnerName: s.partnerAccount.companyName || s.partnerAccount.name,
|
||||||
|
partnerId: s.partnerAccount.id.toString(),
|
||||||
|
createdAt: s.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryStoreInfoAudits(
|
||||||
|
plugin: WecomPluginRuntime,
|
||||||
|
wecomUserId: string,
|
||||||
|
status?: string,
|
||||||
|
page?: string,
|
||||||
|
pageSize?: string,
|
||||||
|
) {
|
||||||
|
this.requirePerm(plugin, 'store.audit.read');
|
||||||
|
const take = clampWecomPluginPageSize(pageSize);
|
||||||
|
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||||
|
const where: Prisma.StoreInfoChangeRequestWhereInput = {};
|
||||||
|
if (status?.trim()) {
|
||||||
|
where.status = status.trim() as Prisma.EnumStoreInfoChangeStatusFilter['equals'];
|
||||||
|
} else {
|
||||||
|
where.status = 'PENDING';
|
||||||
|
}
|
||||||
|
return this.audit.run(
|
||||||
|
{
|
||||||
|
bot: wecomPluginAuditBot(plugin),
|
||||||
|
wecomUserId,
|
||||||
|
action: 'plugin.store.info_audit.list',
|
||||||
|
permission: 'store.audit.read',
|
||||||
|
inputSummary: where.status as string,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
this.prisma.storeInfoChangeRequest.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
include: { store: { select: { name: true } } },
|
||||||
|
}),
|
||||||
|
this.prisma.storeInfoChangeRequest.count({ where }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
items: rows.map((r) => this.mapStoreInfoAuditRow(r)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryStoreInfoAuditDetail(plugin: WecomPluginRuntime, wecomUserId: string, id: string) {
|
||||||
|
this.requirePerm(plugin, 'store.audit.read');
|
||||||
|
return this.audit.run(
|
||||||
|
{
|
||||||
|
bot: wecomPluginAuditBot(plugin),
|
||||||
|
wecomUserId,
|
||||||
|
action: 'plugin.store.info_audit.detail',
|
||||||
|
permission: 'store.audit.read',
|
||||||
|
inputSummary: id,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const row = await this.prisma.storeInfoChangeRequest.findUnique({
|
||||||
|
where: { id: BigInt(id) },
|
||||||
|
include: { store: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('信息变更审核不存在');
|
||||||
|
return this.mapStoreInfoAuditRow(row, true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryStorePackageAudits(
|
||||||
|
plugin: WecomPluginRuntime,
|
||||||
|
wecomUserId: string,
|
||||||
|
status?: string,
|
||||||
|
page?: string,
|
||||||
|
pageSize?: string,
|
||||||
|
) {
|
||||||
|
this.requirePerm(plugin, 'store.audit.read');
|
||||||
|
const take = clampWecomPluginPageSize(pageSize);
|
||||||
|
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||||
|
const where: Prisma.StorePackageChangeRequestWhereInput = {};
|
||||||
|
if (status?.trim()) {
|
||||||
|
where.status = status.trim() as Prisma.EnumStorePackageChangeStatusFilter['equals'];
|
||||||
|
} else {
|
||||||
|
where.status = 'PENDING';
|
||||||
|
}
|
||||||
|
return this.audit.run(
|
||||||
|
{
|
||||||
|
bot: wecomPluginAuditBot(plugin),
|
||||||
|
wecomUserId,
|
||||||
|
action: 'plugin.store.package_audit.list',
|
||||||
|
permission: 'store.audit.read',
|
||||||
|
inputSummary: where.status as string,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
this.prisma.storePackageChangeRequest.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
include: { store: { select: { id: true, name: true } } },
|
||||||
|
}),
|
||||||
|
this.prisma.storePackageChangeRequest.count({ where }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
items: rows.map((r) => ({
|
||||||
|
id: r.id.toString(),
|
||||||
|
storeId: r.storeId.toString(),
|
||||||
|
storeName: r.store.name,
|
||||||
|
status: r.status,
|
||||||
|
packageCount: Array.isArray(r.packagesJson) ? r.packagesJson.length : 0,
|
||||||
|
submitterType: r.submitterType,
|
||||||
|
rejectReason: r.rejectReason,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryStorePackageAuditDetail(plugin: WecomPluginRuntime, wecomUserId: string, id: string) {
|
||||||
|
this.requirePerm(plugin, 'store.audit.read');
|
||||||
|
return this.audit.run(
|
||||||
|
{
|
||||||
|
bot: wecomPluginAuditBot(plugin),
|
||||||
|
wecomUserId,
|
||||||
|
action: 'plugin.store.package_audit.detail',
|
||||||
|
permission: 'store.audit.read',
|
||||||
|
inputSummary: id,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const req = await this.prisma.storePackageChangeRequest.findUnique({
|
||||||
|
where: { id: BigInt(id) },
|
||||||
|
include: { store: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!req) throw new NotFoundException('套餐审核不存在');
|
||||||
|
const livePackages = await this.prisma.storePackage.findMany({
|
||||||
|
where: { storeId: req.storeId },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
select: { name: true, price: true, dishes: true, usableTime: true, otherNotes: true },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: req.id.toString(),
|
||||||
|
storeId: req.storeId.toString(),
|
||||||
|
storeName: req.store.name,
|
||||||
|
status: req.status,
|
||||||
|
proposedPackages: req.packagesJson,
|
||||||
|
livePackages: livePackages.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
price: asNumber(p.price),
|
||||||
|
dishes: p.dishes,
|
||||||
|
usableTime: p.usableTime,
|
||||||
|
otherNotes: p.otherNotes,
|
||||||
|
})),
|
||||||
|
submitterType: req.submitterType,
|
||||||
|
rejectReason: req.rejectReason,
|
||||||
|
reviewedAt: req.reviewedAt?.toISOString() ?? null,
|
||||||
|
createdAt: req.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryPartners(
|
||||||
|
plugin: WecomPluginRuntime,
|
||||||
|
wecomUserId: string,
|
||||||
|
q?: string,
|
||||||
|
page?: string,
|
||||||
|
pageSize?: string,
|
||||||
|
) {
|
||||||
|
this.requirePerm(plugin, 'partner.read');
|
||||||
|
const keyword = requireQuery(q);
|
||||||
|
const take = clampWecomPluginPageSize(pageSize);
|
||||||
|
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||||
|
return this.audit.run(
|
||||||
|
{
|
||||||
|
bot: wecomPluginAuditBot(plugin),
|
||||||
|
wecomUserId,
|
||||||
|
action: 'plugin.partner.read',
|
||||||
|
permission: 'partner.read',
|
||||||
|
inputSummary: keyword,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const or: Prisma.PartnerAccountWhereInput[] = [
|
||||||
|
{ name: { contains: keyword } },
|
||||||
|
{ companyName: { contains: keyword } },
|
||||||
|
{ phone: { contains: keyword } },
|
||||||
|
];
|
||||||
|
if (/^\d+$/.test(keyword)) or.push({ id: BigInt(keyword) });
|
||||||
|
const where: Prisma.PartnerAccountWhereInput = { isPrimary: 1, OR: or };
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
this.prisma.partnerAccount.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
companyName: true,
|
||||||
|
phone: true,
|
||||||
|
status: true,
|
||||||
|
city: { select: { name: true } },
|
||||||
|
_count: { select: { stores: true, assocUsers: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.partnerAccount.count({ where }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
items: rows.map((p) => ({
|
||||||
|
id: p.id.toString(),
|
||||||
|
name: p.name,
|
||||||
|
companyName: p.companyName,
|
||||||
|
phone: maskContactPhone(p.phone),
|
||||||
|
status: p.status,
|
||||||
|
cityName: p.city?.name ?? null,
|
||||||
|
storeCount: p._count.stores,
|
||||||
|
userCount: p._count.assocUsers,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryPartnerUsers(
|
||||||
|
plugin: WecomPluginRuntime,
|
||||||
|
wecomUserId: string,
|
||||||
|
partnerId: string,
|
||||||
|
from?: string,
|
||||||
|
to?: string,
|
||||||
|
page?: string,
|
||||||
|
pageSize?: string,
|
||||||
|
) {
|
||||||
|
this.requirePerm(plugin, 'partner.read');
|
||||||
|
const take = clampWecomPluginPageSize(pageSize);
|
||||||
|
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||||
|
const createdAt = parseWecomPluginDateRange(from, to);
|
||||||
|
return this.audit.run(
|
||||||
|
{
|
||||||
|
bot: wecomPluginAuditBot(plugin),
|
||||||
|
wecomUserId,
|
||||||
|
action: 'plugin.partner.users',
|
||||||
|
permission: 'partner.read',
|
||||||
|
inputSummary: `${partnerId}:${from || ''}-${to || ''}`,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const primary = await this.resolvePartnerPrimary(partnerId);
|
||||||
|
const where: Prisma.UserWhereInput = {
|
||||||
|
assocPartnerAccountId: primary.id,
|
||||||
|
mergedIntoUserId: null,
|
||||||
|
...(createdAt ? { createdAt } : {}),
|
||||||
|
};
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
this.prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
select: {
|
||||||
|
userNo: true,
|
||||||
|
nickname: true,
|
||||||
|
phone: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
_count: { select: { orders: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.user.count({ where }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
partnerId: primary.id.toString(),
|
||||||
|
partnerName: primary.companyName || primary.name,
|
||||||
|
total,
|
||||||
|
items: rows.map((u) => ({
|
||||||
|
...toWecomPluginUserView(u),
|
||||||
|
status: u.status,
|
||||||
|
orderCount: u._count.orders,
|
||||||
|
createdAt: u.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryPartnerStores(
|
||||||
|
plugin: WecomPluginRuntime,
|
||||||
|
wecomUserId: string,
|
||||||
|
partnerId: string,
|
||||||
|
from?: string,
|
||||||
|
to?: string,
|
||||||
|
page?: string,
|
||||||
|
pageSize?: string,
|
||||||
|
) {
|
||||||
|
this.requirePerm(plugin, 'partner.read');
|
||||||
|
const take = clampWecomPluginPageSize(pageSize);
|
||||||
|
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||||
|
const createdAt = parseWecomPluginDateRange(from, to);
|
||||||
|
return this.audit.run(
|
||||||
|
{
|
||||||
|
bot: wecomPluginAuditBot(plugin),
|
||||||
|
wecomUserId,
|
||||||
|
action: 'plugin.partner.stores',
|
||||||
|
permission: 'partner.read',
|
||||||
|
inputSummary: `${partnerId}:${from || ''}-${to || ''}`,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const primary = await this.resolvePartnerPrimary(partnerId);
|
||||||
|
const where: Prisma.StoreWhereInput = {
|
||||||
|
partnerAccountId: primary.id,
|
||||||
|
...(createdAt ? { createdAt } : {}),
|
||||||
|
};
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
this.prisma.store.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
auditStatus: true,
|
||||||
|
cityName: true,
|
||||||
|
rating: true,
|
||||||
|
createdAt: true,
|
||||||
|
_count: { select: { redeemRecords: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.store.count({ where }),
|
||||||
|
]);
|
||||||
|
const redeemSums =
|
||||||
|
rows.length > 0
|
||||||
|
? await this.prisma.redeemRecord.groupBy({
|
||||||
|
by: ['storeId'],
|
||||||
|
where: { storeId: { in: rows.map((s) => s.id) } },
|
||||||
|
_sum: { amount: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const redeemedByStore = new Map(
|
||||||
|
redeemSums.map((r) => [r.storeId.toString(), asNumber(r._sum.amount)]),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
partnerId: primary.id.toString(),
|
||||||
|
partnerName: primary.companyName || primary.name,
|
||||||
|
total,
|
||||||
|
items: rows.map((s) => ({
|
||||||
|
id: s.id.toString(),
|
||||||
|
name: s.name,
|
||||||
|
status: s.status,
|
||||||
|
auditStatus: s.auditStatus,
|
||||||
|
cityName: s.cityName,
|
||||||
|
rating: s.rating != null ? asNumber(s.rating) : null,
|
||||||
|
redeemCount: s._count.redeemRecords,
|
||||||
|
totalRedeemedBenefitAmount: redeemedByStore.get(s.id.toString()) ?? 0,
|
||||||
|
createdAt: s.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryPartnerOrders(
|
||||||
|
plugin: WecomPluginRuntime,
|
||||||
|
wecomUserId: string,
|
||||||
|
partnerId: string,
|
||||||
|
from?: string,
|
||||||
|
to?: string,
|
||||||
|
page?: string,
|
||||||
|
pageSize?: string,
|
||||||
|
) {
|
||||||
|
this.requirePerm(plugin, 'partner.read');
|
||||||
|
const take = clampWecomPluginPageSize(pageSize);
|
||||||
|
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||||
|
const createdAt = parseWecomPluginDateRange(from, to);
|
||||||
|
return this.audit.run(
|
||||||
|
{
|
||||||
|
bot: wecomPluginAuditBot(plugin),
|
||||||
|
wecomUserId,
|
||||||
|
action: 'plugin.partner.orders',
|
||||||
|
permission: 'partner.read',
|
||||||
|
inputSummary: `${partnerId}:${from || ''}-${to || ''}`,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const primary = await this.resolvePartnerPrimary(partnerId);
|
||||||
|
const where: Prisma.OrderWhereInput = {
|
||||||
|
partnerAccountIdAtPay: primary.id,
|
||||||
|
...(createdAt ? { createdAt } : {}),
|
||||||
|
};
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
this.prisma.order.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
select: {
|
||||||
|
orderNo: true,
|
||||||
|
status: true,
|
||||||
|
payStatus: true,
|
||||||
|
productName: true,
|
||||||
|
quantity: true,
|
||||||
|
payAmount: true,
|
||||||
|
createdAt: true,
|
||||||
|
user: { select: { userNo: true, nickname: true, phone: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.order.count({ where }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
partnerId: primary.id.toString(),
|
||||||
|
partnerName: primary.companyName || primary.name,
|
||||||
|
total,
|
||||||
|
items: rows.map((o) => ({
|
||||||
|
orderNo: o.orderNo,
|
||||||
|
status: o.status,
|
||||||
|
payStatus: o.payStatus,
|
||||||
|
productName: o.productName,
|
||||||
|
quantity: o.quantity,
|
||||||
|
payAmount: asNumber(o.payAmount),
|
||||||
|
user: toWecomPluginUserView({
|
||||||
|
userNo: o.user?.userNo || '—',
|
||||||
|
nickname: o.user?.nickname,
|
||||||
|
phone: o.user?.phone,
|
||||||
|
}),
|
||||||
|
createdAt: o.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapStoreInfoAuditRow(
|
||||||
|
row: {
|
||||||
|
id: bigint;
|
||||||
|
storeId: bigint;
|
||||||
|
status: string;
|
||||||
|
liveSnapshot: unknown;
|
||||||
|
proposedSnapshot: unknown;
|
||||||
|
changedFields: unknown;
|
||||||
|
submitterType: string;
|
||||||
|
submitterId: bigint;
|
||||||
|
rejectReason: string | null;
|
||||||
|
reviewedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
store?: { name?: string | null };
|
||||||
|
},
|
||||||
|
withDiffs = false,
|
||||||
|
) {
|
||||||
|
const changed = Array.isArray(row.changedFields)
|
||||||
|
? (row.changedFields as StoreInfoChangeableField[])
|
||||||
|
: [];
|
||||||
|
const live = (row.liveSnapshot || {}) as Record<string, unknown>;
|
||||||
|
const proposed = (row.proposedSnapshot || {}) as Record<string, unknown>;
|
||||||
|
const base = {
|
||||||
|
id: row.id.toString(),
|
||||||
|
storeId: row.storeId.toString(),
|
||||||
|
storeName: row.store?.name ?? null,
|
||||||
|
status: row.status,
|
||||||
|
changedFields: changed,
|
||||||
|
changedFieldLabels: changed.map((f) => STORE_INFO_CHANGEABLE_FIELD_LABELS[f] ?? f),
|
||||||
|
submitterType: row.submitterType,
|
||||||
|
submitterId: row.submitterId.toString(),
|
||||||
|
rejectReason: row.rejectReason,
|
||||||
|
reviewedAt: row.reviewedAt?.toISOString() ?? null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
if (!withDiffs) return base;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
diffs: changed.map((field) => ({
|
||||||
|
field,
|
||||||
|
label: STORE_INFO_CHANGEABLE_FIELD_LABELS[field] ?? field,
|
||||||
|
live: live[field] ?? null,
|
||||||
|
proposed: proposed[field] ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolvePartnerPrimary(partnerId: string) {
|
||||||
|
const id = BigInt(partnerId);
|
||||||
|
const account = await this.prisma.partnerAccount.findUnique({ where: { id } });
|
||||||
|
if (!account) throw new NotFoundException('合伙人不存在');
|
||||||
|
if (account.isPrimary === 1) return account;
|
||||||
|
if (!account.parentAccountId) throw new NotFoundException('合伙人账号无效');
|
||||||
|
return this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: account.parentAccountId } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
|
|
||||||
|
|
||||||
/** 审计占位:插件无长连接 Bot 行,botKey 固定为 plugin */
|
|
||||||
export const WECOM_PLUGIN_AUDIT_BOT: WecomBotRuntimeConfig = {
|
|
||||||
id: '',
|
|
||||||
key: 'plugin',
|
|
||||||
role: 'OPERATIONS',
|
|
||||||
name: '企微 API 插件',
|
|
||||||
enabled: true,
|
|
||||||
botId: '',
|
|
||||||
secret: '',
|
|
||||||
welcome: '',
|
|
||||||
avatarUrl: null,
|
|
||||||
permissions: [],
|
|
||||||
reviewSuperAdminWecomUserIds: [],
|
|
||||||
aiEnabled: false,
|
|
||||||
llmConfigId: null,
|
|
||||||
knowledgeBaseId: null,
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,8 @@ import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
|||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { WecomPluginGuard } from './wecom-plugin.guard';
|
import { WecomPluginGuard } from './wecom-plugin.guard';
|
||||||
import { WecomPluginQueryService } from './wecom-plugin-query.service';
|
import { WecomPluginQueryService } from './wecom-plugin-query.service';
|
||||||
import { WECOM_PLUGIN_OPENAPI } from './wecom-plugin.openapi';
|
import { CurrentWecomPlugin } from './wecom-plugin.decorators';
|
||||||
|
import type { WecomPluginRuntime } from './wecom-plugin.types';
|
||||||
|
|
||||||
@Controller('wecom/plugin')
|
@Controller('wecom/plugin')
|
||||||
@UseGuards(WecomPluginGuard)
|
@UseGuards(WecomPluginGuard)
|
||||||
@@ -10,73 +11,212 @@ export class WecomPluginController {
|
|||||||
constructor(private readonly query: WecomPluginQueryService) {}
|
constructor(private readonly query: WecomPluginQueryService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
info() {
|
info(@CurrentWecomPlugin() plugin: WecomPluginRuntime) {
|
||||||
return this.query.info();
|
return this.query.info(plugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('openapi.json')
|
@Get('openapi.json')
|
||||||
openapi() {
|
openapi(@CurrentWecomPlugin() plugin: WecomPluginRuntime) {
|
||||||
return WECOM_PLUGIN_OPENAPI;
|
return this.query.openapi(plugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('orders')
|
@Get('orders')
|
||||||
orders(
|
orders(
|
||||||
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Query('q') q?: string,
|
@Query('q') q?: string,
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
) {
|
) {
|
||||||
return this.query.queryOrders(pluginCaller(req), q, page, pageSize);
|
return this.query.queryOrders(plugin, pluginCaller(req), q, page, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('users')
|
@Get('users')
|
||||||
users(
|
users(
|
||||||
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Query('q') q?: string,
|
@Query('q') q?: string,
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
) {
|
) {
|
||||||
return this.query.queryUsers(pluginCaller(req), q, page, pageSize);
|
return this.query.queryUsers(plugin, pluginCaller(req), q, page, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('stores')
|
@Get('stores')
|
||||||
stores(
|
stores(
|
||||||
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Query('q') q?: string,
|
@Query('q') q?: string,
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
) {
|
) {
|
||||||
return this.query.queryStores(pluginCaller(req), q, page, pageSize);
|
return this.query.queryStores(plugin, pluginCaller(req), q, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('store-audits')
|
||||||
|
storeAudits(
|
||||||
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Query('q') q?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.query.queryStoreAudits(plugin, pluginCaller(req), q, status, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('store-info-audits')
|
||||||
|
storeInfoAudits(
|
||||||
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.query.queryStoreInfoAudits(plugin, pluginCaller(req), status, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('store-info-audits/:id')
|
||||||
|
storeInfoAuditDetail(
|
||||||
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.query.queryStoreInfoAuditDetail(plugin, pluginCaller(req), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('store-package-audits')
|
||||||
|
storePackageAudits(
|
||||||
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.query.queryStorePackageAudits(plugin, pluginCaller(req), status, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('store-package-audits/:id')
|
||||||
|
storePackageAuditDetail(
|
||||||
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.query.queryStorePackageAuditDetail(plugin, pluginCaller(req), id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('redeems')
|
@Get('redeems')
|
||||||
redeems(
|
redeems(
|
||||||
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Query('q') q?: string,
|
@Query('q') q?: string,
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
) {
|
) {
|
||||||
return this.query.queryRedeems(pluginCaller(req), q, page, pageSize);
|
return this.query.queryRedeems(plugin, pluginCaller(req), q, page, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('promo-codes')
|
@Get('promo-codes')
|
||||||
promoCodes(
|
promoCodes(
|
||||||
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Query('q') q?: string,
|
@Query('q') q?: string,
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
) {
|
) {
|
||||||
return this.query.queryPromoCodes(pluginCaller(req), q, page, pageSize);
|
return this.query.queryPromoCodes(plugin, pluginCaller(req), q, page, pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('promo-codes/:code/stats')
|
@Get('promo-codes/:code/stats')
|
||||||
promoStats(@Req() req: Request, @Param('code') code: string) {
|
promoStats(
|
||||||
return this.query.queryPromoCodeStats(pluginCaller(req), code);
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Param('code') code: string,
|
||||||
|
) {
|
||||||
|
return this.query.queryPromoCodeStats(plugin, pluginCaller(req), code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('partners')
|
||||||
|
partners(
|
||||||
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Query('q') q?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.query.queryPartners(plugin, pluginCaller(req), q, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('partners/:partnerId/users')
|
||||||
|
partnerUsers(
|
||||||
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Param('partnerId') partnerId: string,
|
||||||
|
@Query('from') from?: string,
|
||||||
|
@Query('to') to?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.query.queryPartnerUsers(
|
||||||
|
plugin,
|
||||||
|
pluginCaller(req),
|
||||||
|
partnerId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('partners/:partnerId/stores')
|
||||||
|
partnerStores(
|
||||||
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Param('partnerId') partnerId: string,
|
||||||
|
@Query('from') from?: string,
|
||||||
|
@Query('to') to?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.query.queryPartnerStores(
|
||||||
|
plugin,
|
||||||
|
pluginCaller(req),
|
||||||
|
partnerId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('partners/:partnerId/orders')
|
||||||
|
partnerOrders(
|
||||||
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Param('partnerId') partnerId: string,
|
||||||
|
@Query('from') from?: string,
|
||||||
|
@Query('to') to?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.query.queryPartnerOrders(
|
||||||
|
plugin,
|
||||||
|
pluginCaller(req),
|
||||||
|
partnerId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('metrics')
|
@Get('metrics')
|
||||||
metrics(@Req() req: Request, @Query('kind') kind?: string) {
|
metrics(
|
||||||
return this.query.queryMetrics(pluginCaller(req), kind);
|
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Query('kind') kind?: string,
|
||||||
|
) {
|
||||||
|
return this.query.queryMetrics(plugin, pluginCaller(req), kind);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { WecomPluginRuntime } from './wecom-plugin.types';
|
||||||
|
|
||||||
|
export const CurrentWecomPlugin = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext): WecomPluginRuntime => {
|
||||||
|
return ctx.switchToHttp().getRequest().wecomPlugin;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -4,19 +4,24 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { isWecomPluginEnabled, verifyWecomPluginApiKey } from '@dukang/domain';
|
import { WecomPluginAuthService } from './wecom-plugin-auth.service';
|
||||||
|
import type { WecomPluginRuntime } from './wecom-plugin.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WecomPluginGuard implements CanActivate {
|
export class WecomPluginGuard implements CanActivate {
|
||||||
canActivate(context: ExecutionContext): boolean {
|
constructor(private readonly auth: WecomPluginAuthService) {}
|
||||||
if (!isWecomPluginEnabled(process.env)) {
|
|
||||||
throw new UnauthorizedException('Unauthorized');
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
}
|
const req = context.switchToHttp().getRequest<{
|
||||||
const req = context.switchToHttp().getRequest<{ headers: Record<string, unknown> }>();
|
headers: Record<string, unknown>;
|
||||||
|
wecomPlugin?: WecomPluginRuntime;
|
||||||
|
}>();
|
||||||
const provided = headerValue(req.headers, 'x-api-key');
|
const provided = headerValue(req.headers, 'x-api-key');
|
||||||
if (!verifyWecomPluginApiKey(provided, process.env.WECOM_PLUGIN_API_KEY)) {
|
const plugin = await this.auth.resolveByApiKey(provided);
|
||||||
|
if (!plugin) {
|
||||||
throw new UnauthorizedException('Unauthorized');
|
throw new UnauthorizedException('Unauthorized');
|
||||||
}
|
}
|
||||||
|
req.wecomPlugin = plugin;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { WecomPluginPermission } from '@dukang/shared-types';
|
||||||
|
import { allowedWecomPluginOpenApiPaths } from '@dukang/shared-types';
|
||||||
|
|
||||||
const envelope = (dataSchema: Record<string, unknown>) => ({
|
const envelope = (dataSchema: Record<string, unknown>) => ({
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -32,6 +35,23 @@ const pageParams = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const dateRangeParams = [
|
||||||
|
{
|
||||||
|
name: 'from',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
schema: { type: 'string' },
|
||||||
|
description: '起始日期 YYYY-MM-DD 或 ISO',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'to',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
schema: { type: 'string' },
|
||||||
|
description: '结束日期 YYYY-MM-DD 或 ISO(含当天)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const unauthorized = {
|
const unauthorized = {
|
||||||
description: '缺少或错误的 X-Api-Key,或插件未启用',
|
description: '缺少或错误的 X-Api-Key,或插件未启用',
|
||||||
content: {
|
content: {
|
||||||
@@ -75,6 +95,27 @@ function listPath(summary: string, description: string, qDescription: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按实例权限过滤 paths,供企微第 2 步对照配置 */
|
||||||
|
export function filterWecomPluginOpenApi(
|
||||||
|
spec: typeof WECOM_PLUGIN_OPENAPI,
|
||||||
|
permissions: WecomPluginPermission[],
|
||||||
|
title?: string,
|
||||||
|
) {
|
||||||
|
const allowedPaths = new Set(allowedWecomPluginOpenApiPaths(permissions));
|
||||||
|
const paths: Record<string, unknown> = {};
|
||||||
|
for (const [path, def] of Object.entries(spec.paths)) {
|
||||||
|
if (allowedPaths.has(path)) paths[path] = def;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...spec,
|
||||||
|
info: {
|
||||||
|
...spec.info,
|
||||||
|
title: title || spec.info.title,
|
||||||
|
},
|
||||||
|
paths,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** OpenAPI 3.0:企微「添加插件工具」可导入。须原样返回,不要套 {code,message,data}。 */
|
/** OpenAPI 3.0:企微「添加插件工具」可导入。须原样返回,不要套 {code,message,data}。 */
|
||||||
export const WECOM_PLUGIN_OPENAPI = {
|
export const WECOM_PLUGIN_OPENAPI = {
|
||||||
openapi: '3.0.3',
|
openapi: '3.0.3',
|
||||||
@@ -101,7 +142,141 @@ export const WECOM_PLUGIN_OPENAPI = {
|
|||||||
paths: {
|
paths: {
|
||||||
'/orders': listPath('查询订单', '按订单号模糊查询', '订单号,如 DK20260903xxxx'),
|
'/orders': listPath('查询订单', '按订单号模糊查询', '订单号,如 DK20260903xxxx'),
|
||||||
'/users': listPath('查询用户', '按用户号或 11 位手机号查询;手机号脱敏', '用户号或手机号'),
|
'/users': listPath('查询用户', '按用户号或 11 位手机号查询;手机号脱敏', '用户号或手机号'),
|
||||||
'/stores': listPath('查询门店', '按门店名称模糊查询', '门店名称关键词'),
|
'/stores': listPath(
|
||||||
|
'查询门店',
|
||||||
|
'按门店名称模糊查询,返回经营数据:评分、核销笔数、累计核销好客权益、合伙人、审核状态',
|
||||||
|
'门店名称关键词',
|
||||||
|
),
|
||||||
|
'/store-audits': {
|
||||||
|
get: {
|
||||||
|
summary: '门店入驻审核列表',
|
||||||
|
description: '默认 status=PENDING;可按门店名筛选',
|
||||||
|
operationId: '查询门店入驻审核',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'q', in: 'query', required: false, schema: { type: 'string' }, description: '门店名称' },
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
schema: { type: 'string', enum: ['PENDING', 'APPROVED', 'REJECTED'], default: 'PENDING' },
|
||||||
|
},
|
||||||
|
...pageParams,
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: { description: '审核列表', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
|
||||||
|
401: unauthorized,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/store-info-audits': {
|
||||||
|
get: {
|
||||||
|
summary: '门店信息变更审核列表',
|
||||||
|
operationId: '查询门店信息变更审核',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
schema: { type: 'string', enum: ['PENDING', 'APPROVED', 'REJECTED'], default: 'PENDING' },
|
||||||
|
},
|
||||||
|
...pageParams,
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: { description: '列表', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
|
||||||
|
401: unauthorized,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/store-info-audits/{id}': {
|
||||||
|
get: {
|
||||||
|
summary: '门店信息变更审核对比',
|
||||||
|
description: '返回变更字段 live vs proposed 对照',
|
||||||
|
operationId: '查询门店信息变更详情',
|
||||||
|
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
||||||
|
responses: {
|
||||||
|
200: { description: '详情含 diffs', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
|
||||||
|
401: unauthorized,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/store-package-audits': {
|
||||||
|
get: {
|
||||||
|
summary: '门店套餐审核列表',
|
||||||
|
operationId: '查询门店套餐审核',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
schema: { type: 'string', enum: ['PENDING', 'APPROVED', 'REJECTED'], default: 'PENDING' },
|
||||||
|
},
|
||||||
|
...pageParams,
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: { description: '列表', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
|
||||||
|
401: unauthorized,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/store-package-audits/{id}': {
|
||||||
|
get: {
|
||||||
|
summary: '门店套餐审核对比',
|
||||||
|
description: '返回 proposedPackages 与 livePackages 对照',
|
||||||
|
operationId: '查询门店套餐审核详情',
|
||||||
|
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
||||||
|
responses: {
|
||||||
|
200: { description: '详情含套餐对比', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
|
||||||
|
401: unauthorized,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/partners': listPath('查询合伙人', '按姓名、公司名、手机号或 ID 搜索主账号', '合伙人关键词'),
|
||||||
|
'/partners/{partnerId}/users': {
|
||||||
|
get: {
|
||||||
|
summary: '合伙人关联用户',
|
||||||
|
operationId: '查询合伙人关联用户',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'partnerId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
...dateRangeParams,
|
||||||
|
...pageParams,
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: { description: '关联用户列表', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
|
||||||
|
401: unauthorized,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/partners/{partnerId}/stores': {
|
||||||
|
get: {
|
||||||
|
summary: '合伙人名下门店',
|
||||||
|
operationId: '查询合伙人门店',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'partnerId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
...dateRangeParams,
|
||||||
|
...pageParams,
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: { description: '门店及经营数据', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
|
||||||
|
401: unauthorized,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/partners/{partnerId}/orders': {
|
||||||
|
get: {
|
||||||
|
summary: '合伙人相关订单',
|
||||||
|
description: '佣金归属 partnerAccountIdAtPay 的订单',
|
||||||
|
operationId: '查询合伙人订单',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'partnerId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
...dateRangeParams,
|
||||||
|
...pageParams,
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: { description: '订单列表', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
|
||||||
|
401: unauthorized,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
'/redeems': listPath('查询核销', '按核销单号或门店名查询', '核销单号或门店名'),
|
'/redeems': listPath('查询核销', '按核销单号或门店名查询', '核销单号或门店名'),
|
||||||
'/promo-codes': listPath('查询推广码', '按推广码 code 或名称查询', '推广码或名称'),
|
'/promo-codes': listPath('查询推广码', '按推广码 code 或名称查询', '推广码或名称'),
|
||||||
'/promo-codes/{code}/stats': {
|
'/promo-codes/{code}/stats': {
|
||||||
@@ -130,7 +305,7 @@ export const WECOM_PLUGIN_OPENAPI = {
|
|||||||
get: {
|
get: {
|
||||||
summary: '经营指标',
|
summary: '经营指标',
|
||||||
description:
|
description:
|
||||||
'today=今日截至当前;daily/weekly/monthly 与企微经营报告同一口径(用户有效未合并,订单金额=已付 payAmount)。',
|
'today=今日截至当前;daily/weekly/monthly 与企微经营报告同一口径。stats 含 users/partners/stores/orders 存量与增量;storesIncrement 与 newStores 均为新增门店数。',
|
||||||
operationId: '查询经营指标',
|
operationId: '查询经营指标',
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { WecomPluginPermission } from '@dukang/shared-types';
|
||||||
|
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
|
||||||
|
|
||||||
|
export type WecomPluginRuntime = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
permissions: WecomPluginPermission[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function wecomPluginAuditBot(plugin: WecomPluginRuntime): WecomBotRuntimeConfig {
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
key: `plugin:${plugin.id}`,
|
||||||
|
role: 'OPERATIONS',
|
||||||
|
name: plugin.name,
|
||||||
|
enabled: true,
|
||||||
|
botId: '',
|
||||||
|
secret: '',
|
||||||
|
welcome: '',
|
||||||
|
avatarUrl: null,
|
||||||
|
permissions: [],
|
||||||
|
reviewSuperAdminWecomUserIds: [],
|
||||||
|
aiEnabled: false,
|
||||||
|
llmConfigId: null,
|
||||||
|
knowledgeBaseId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { WecomBotAiService } from './wecom-bot-ai.service';
|
|||||||
import { WecomBotAuditService } from './wecom-bot-audit.service';
|
import { WecomBotAuditService } from './wecom-bot-audit.service';
|
||||||
import { WecomBotCapabilityService } from './wecom-bot-capability.service';
|
import { WecomBotCapabilityService } from './wecom-bot-capability.service';
|
||||||
import { WecomBotSessionService } from './wecom-bot-session.service';
|
import { WecomBotSessionService } from './wecom-bot-session.service';
|
||||||
|
import { WecomPluginAuthService } from './wecom-plugin-auth.service';
|
||||||
import { WecomPluginController } from './wecom-plugin.controller';
|
import { WecomPluginController } from './wecom-plugin.controller';
|
||||||
import { WecomPluginGuard } from './wecom-plugin.guard';
|
import { WecomPluginGuard } from './wecom-plugin.guard';
|
||||||
import { WecomPluginQueryService } from './wecom-plugin-query.service';
|
import { WecomPluginQueryService } from './wecom-plugin-query.service';
|
||||||
@@ -33,6 +34,7 @@ import { WecomPluginQueryService } from './wecom-plugin-query.service';
|
|||||||
WecomBotActionsService,
|
WecomBotActionsService,
|
||||||
WecomBotAiService,
|
WecomBotAiService,
|
||||||
WecomAibotService,
|
WecomAibotService,
|
||||||
|
WecomPluginAuthService,
|
||||||
WecomPluginGuard,
|
WecomPluginGuard,
|
||||||
WecomPluginQueryService,
|
WecomPluginQueryService,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
CreateWecomApiPluginRequest,
|
||||||
|
UpdateWecomApiPluginRequest,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import {
|
||||||
|
HqPermissionGuard,
|
||||||
|
RequireHqPermissions,
|
||||||
|
} from '../../common/guards/hq-permission.guard';
|
||||||
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
|
import { AdminWecomApiPluginsService } from './admin-wecom-api-plugins.service';
|
||||||
|
|
||||||
|
@Controller('admin/wecom-api-plugins')
|
||||||
|
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||||
|
@RequireHqPermissions('wecom_bots')
|
||||||
|
export class AdminWecomApiPluginsController {
|
||||||
|
constructor(private readonly service: AdminWecomApiPluginsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query('name') name?: string,
|
||||||
|
@Query('enabled') enabled?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.service.list({
|
||||||
|
name,
|
||||||
|
enabled,
|
||||||
|
page: page ? Number(page) : undefined,
|
||||||
|
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detail(@Param('id') id: string) {
|
||||||
|
return this.service.detail(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.WECOM_API_PLUGIN_CREATE,
|
||||||
|
refType: 'WECOM_API_PLUGIN',
|
||||||
|
includeBody: false,
|
||||||
|
})
|
||||||
|
create(@Body() body: CreateWecomApiPluginRequest) {
|
||||||
|
return this.service.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.WECOM_API_PLUGIN_UPDATE,
|
||||||
|
refType: 'WECOM_API_PLUGIN',
|
||||||
|
refIdField: 'id',
|
||||||
|
includeBody: false,
|
||||||
|
})
|
||||||
|
update(@Param('id') id: string, @Body() body: UpdateWecomApiPluginRequest) {
|
||||||
|
return this.service.update(BigInt(id), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.WECOM_API_PLUGIN_DELETE,
|
||||||
|
refType: 'WECOM_API_PLUGIN',
|
||||||
|
refIdField: 'id',
|
||||||
|
})
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.service.remove(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/rotate-key')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.WECOM_API_PLUGIN_ROTATE_KEY,
|
||||||
|
refType: 'WECOM_API_PLUGIN',
|
||||||
|
refIdField: 'id',
|
||||||
|
})
|
||||||
|
rotateKey(@Param('id') id: string) {
|
||||||
|
return this.service.rotateKey(BigInt(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
maskWecomPluginApiKey,
|
||||||
|
parseWecomPluginPermissions,
|
||||||
|
type CreateWecomApiPluginRequest,
|
||||||
|
type UpdateWecomApiPluginRequest,
|
||||||
|
type WecomApiPluginDto,
|
||||||
|
type WecomApiPluginSecretDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
const MIN_KEY_LEN = 16;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminWecomApiPluginsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(query: { name?: string; enabled?: string; page?: number; pageSize?: number }) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const where: { name?: { contains: string }; enabled?: boolean } = {};
|
||||||
|
if (query.name?.trim()) where.name = { contains: query.name.trim() };
|
||||||
|
if (query.enabled === 'true' || query.enabled === 'false') {
|
||||||
|
where.enabled = query.enabled === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.wecomApiPlugin.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.wecomApiPlugin.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return serializeBigInt({
|
||||||
|
items: items.map((row) => this.toDto(row)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: bigint): Promise<WecomApiPluginDto> {
|
||||||
|
return this.toDto(await this.mustGet(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateWecomApiPluginRequest): Promise<WecomApiPluginSecretDto> {
|
||||||
|
const name = dto.name?.trim();
|
||||||
|
if (!name) throw new BadRequestException('请填写名称');
|
||||||
|
const permissions = parseWecomPluginPermissions(dto.permissions);
|
||||||
|
if (!permissions.length) throw new BadRequestException('请至少勾选一项工具权限');
|
||||||
|
const apiKey = this.normalizeApiKey(dto.apiKey, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.wecomApiPlugin.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
apiKey,
|
||||||
|
permissions: JSON.stringify(permissions),
|
||||||
|
remark: dto.remark?.trim() || null,
|
||||||
|
enabled: dto.enabled !== false,
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ...this.toDto(row), apiKey };
|
||||||
|
} catch (e) {
|
||||||
|
this.rethrowUniqueKey(e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: bigint, dto: UpdateWecomApiPluginRequest): Promise<WecomApiPluginDto> {
|
||||||
|
const existing = await this.mustGet(id);
|
||||||
|
const data: Prisma.WecomApiPluginUpdateInput = {};
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
const name = dto.name.trim();
|
||||||
|
if (!name) throw new BadRequestException('请填写名称');
|
||||||
|
data.name = name;
|
||||||
|
}
|
||||||
|
if (dto.permissions !== undefined) {
|
||||||
|
const permissions = parseWecomPluginPermissions(dto.permissions);
|
||||||
|
if (!permissions.length) throw new BadRequestException('请至少勾选一项工具权限');
|
||||||
|
data.permissions = JSON.stringify(permissions);
|
||||||
|
}
|
||||||
|
if (dto.remark !== undefined) data.remark = dto.remark?.trim() || null;
|
||||||
|
if (dto.enabled !== undefined) data.enabled = dto.enabled;
|
||||||
|
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
||||||
|
if (dto.apiKey !== undefined && String(dto.apiKey).trim()) {
|
||||||
|
data.apiKey = this.normalizeApiKey(dto.apiKey, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.wecomApiPlugin.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return this.toDto(row);
|
||||||
|
} catch (e) {
|
||||||
|
this.rethrowUniqueKey(e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: bigint) {
|
||||||
|
await this.mustGet(id);
|
||||||
|
await this.prisma.wecomApiPlugin.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async rotateKey(id: bigint): Promise<WecomApiPluginSecretDto> {
|
||||||
|
await this.mustGet(id);
|
||||||
|
const apiKey = generateApiKey();
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.wecomApiPlugin.update({
|
||||||
|
where: { id },
|
||||||
|
data: { apiKey },
|
||||||
|
});
|
||||||
|
return { ...this.toDto(row), apiKey };
|
||||||
|
} catch (e) {
|
||||||
|
this.rethrowUniqueKey(e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mustGet(id: bigint) {
|
||||||
|
const row = await this.prisma.wecomApiPlugin.findUnique({ where: { id } });
|
||||||
|
if (!row) throw new NotFoundException('API 插件不存在');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeApiKey(raw: string | undefined, generateIfEmpty: boolean): string {
|
||||||
|
const v = String(raw ?? '').trim();
|
||||||
|
if (!v) {
|
||||||
|
if (generateIfEmpty) return generateApiKey();
|
||||||
|
throw new BadRequestException('请填写 API Key');
|
||||||
|
}
|
||||||
|
if (v.length < MIN_KEY_LEN) {
|
||||||
|
throw new BadRequestException(`API Key 至少 ${MIN_KEY_LEN} 位`);
|
||||||
|
}
|
||||||
|
if (v.length > 128) throw new BadRequestException('API Key 过长');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rethrowUniqueKey(e: unknown): void {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new BadRequestException('API Key 已存在,请更换');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: {
|
||||||
|
id: bigint;
|
||||||
|
name: string;
|
||||||
|
apiKey: string;
|
||||||
|
permissions: string;
|
||||||
|
remark: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): WecomApiPluginDto {
|
||||||
|
return {
|
||||||
|
id: row.id.toString(),
|
||||||
|
name: row.name,
|
||||||
|
apiKeyConfigured: Boolean(row.apiKey),
|
||||||
|
apiKeyMasked: maskWecomPluginApiKey(row.apiKey),
|
||||||
|
permissions: parseWecomPluginPermissions(row.permissions),
|
||||||
|
remark: row.remark,
|
||||||
|
enabled: row.enabled,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateApiKey(): string {
|
||||||
|
return `dkp_${randomBytes(24).toString('hex')}`;
|
||||||
|
}
|
||||||
@@ -69,6 +69,8 @@ import { AdminWecomBotsController } from './admin-wecom-bots.controller';
|
|||||||
import { AdminWecomBotsService } from './admin-wecom-bots.service';
|
import { AdminWecomBotsService } from './admin-wecom-bots.service';
|
||||||
import { AdminWecomMessagePushesController } from './admin-wecom-message-pushes.controller';
|
import { AdminWecomMessagePushesController } from './admin-wecom-message-pushes.controller';
|
||||||
import { AdminWecomMessagePushesService } from './admin-wecom-message-pushes.service';
|
import { AdminWecomMessagePushesService } from './admin-wecom-message-pushes.service';
|
||||||
|
import { AdminWecomApiPluginsController } from './admin-wecom-api-plugins.controller';
|
||||||
|
import { AdminWecomApiPluginsService } from './admin-wecom-api-plugins.service';
|
||||||
import { AdminWecomReportsController } from './admin-wecom-reports.controller';
|
import { AdminWecomReportsController } from './admin-wecom-reports.controller';
|
||||||
import { AdminWecomReportsService } from './admin-wecom-reports.service';
|
import { AdminWecomReportsService } from './admin-wecom-reports.service';
|
||||||
import { AdminWecomPushTemplatesController } from './admin-wecom-push-templates.controller';
|
import { AdminWecomPushTemplatesController } from './admin-wecom-push-templates.controller';
|
||||||
@@ -130,6 +132,7 @@ import { PartnerActivityPostersController } from './partner-activity-posters.con
|
|||||||
AdminSystemConfigController,
|
AdminSystemConfigController,
|
||||||
AdminWecomBotsController,
|
AdminWecomBotsController,
|
||||||
AdminWecomMessagePushesController,
|
AdminWecomMessagePushesController,
|
||||||
|
AdminWecomApiPluginsController,
|
||||||
AdminWecomReportsController,
|
AdminWecomReportsController,
|
||||||
AdminWecomPushTemplatesController,
|
AdminWecomPushTemplatesController,
|
||||||
AdminWecomBotLogsController,
|
AdminWecomBotLogsController,
|
||||||
@@ -169,6 +172,7 @@ import { PartnerActivityPostersController } from './partner-activity-posters.con
|
|||||||
AdminDeployService,
|
AdminDeployService,
|
||||||
AdminWecomBotsService,
|
AdminWecomBotsService,
|
||||||
AdminWecomMessagePushesService,
|
AdminWecomMessagePushesService,
|
||||||
|
AdminWecomApiPluginsService,
|
||||||
AdminWecomReportsService,
|
AdminWecomReportsService,
|
||||||
AdminWecomBotLogsService,
|
AdminWecomBotLogsService,
|
||||||
AdminLlmConfigsService,
|
AdminLlmConfigsService,
|
||||||
|
|||||||
Reference in New Issue
Block a user