From 192a4012271a3bd0b26ca250d229b2c5587f37fa Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 6 Sep 2026 09:36:49 +0800 Subject: [PATCH] =?UTF-8?q?v4.0.17=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1API?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/App.tsx | 2 + apps/admin-web/src/layouts/AdminLayout.tsx | 2 + .../src/pages/WecomApiPluginsPage.tsx | 405 +++++++++++ apps/admin-web/src/pages/WecomBotsPage.tsx | 29 +- deploy/git-push-dev-jacy.sh | 31 + deploy/git-sync-to-dev.sh | 50 ++ deploy/git-sync-to-main.sh | 48 ++ docs/杜康好客-v3-现状对照.md | 2 + docs/杜康好客-v3.5.17-开发文档.md | 94 +++ docs/杜康好客-v3编码手册.md | 2 +- docs/杜康好客-知识库.md | 2 +- packages/domain/src/wecom-plugin.test.ts | 43 ++ packages/domain/src/wecom-plugin.ts | 62 +- .../shared-types/src/wecom-plugin.test.ts | 46 ++ packages/shared-types/src/wecom-plugin.ts | 182 +++++ server/dukang-api/.env.example | 4 - server/dukang-api/.env.production.example | 4 - server/dukang-api/.env.staging.example | 4 - server/dukang-api/package.json | 1 + .../prisma/migrate-wecom-api-plugin.ts | 35 + server/dukang-api/prisma/schema.prisma | 17 + .../scripts/gen-staging-env-from-prod.cjs | 1 - .../hq-operation/hq-operation.constants.ts | 8 + .../wecom/wecom-bot-audit.service.ts | 2 +- .../wecom/wecom-plugin-auth.service.ts | 59 ++ .../wecom/wecom-plugin-query.service.ts | 679 +++++++++++++++++- .../wecom/wecom-plugin.constants.ts | 19 - .../wecom/wecom-plugin.controller.ts | 168 ++++- .../wecom/wecom-plugin.decorators.ts | 8 + .../integrations/wecom/wecom-plugin.guard.ts | 19 +- .../wecom/wecom-plugin.openapi.ts | 179 ++++- .../integrations/wecom/wecom-plugin.types.ts | 27 + .../src/integrations/wecom/wecom.module.ts | 2 + .../ops/admin-wecom-api-plugins.controller.ts | 91 +++ .../ops/admin-wecom-api-plugins.service.ts | 188 +++++ .../dukang-api/src/modules/ops/ops.module.ts | 4 + 36 files changed, 2416 insertions(+), 103 deletions(-) create mode 100644 apps/admin-web/src/pages/WecomApiPluginsPage.tsx create mode 100644 deploy/git-push-dev-jacy.sh create mode 100644 deploy/git-sync-to-dev.sh create mode 100644 deploy/git-sync-to-main.sh create mode 100644 docs/杜康好客-v3.5.17-开发文档.md create mode 100644 server/dukang-api/prisma/migrate-wecom-api-plugin.ts create mode 100644 server/dukang-api/src/integrations/wecom/wecom-plugin-auth.service.ts delete mode 100644 server/dukang-api/src/integrations/wecom/wecom-plugin.constants.ts create mode 100644 server/dukang-api/src/integrations/wecom/wecom-plugin.decorators.ts create mode 100644 server/dukang-api/src/integrations/wecom/wecom-plugin.types.ts create mode 100644 server/dukang-api/src/modules/ops/admin-wecom-api-plugins.controller.ts create mode 100644 server/dukang-api/src/modules/ops/admin-wecom-api-plugins.service.ts diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 8d16802..8573032 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -50,6 +50,7 @@ import HqPermissionsPage from './pages/HqPermissionsPage'; import SystemSettingsPage from './pages/SystemSettingsPage'; import TestWhitelistPage from './pages/TestWhitelistPage'; import WecomBotsPage from './pages/WecomBotsPage'; +import WecomApiPluginsPage from './pages/WecomApiPluginsPage'; import WecomMessagePushesPage from './pages/WecomMessagePushesPage'; import WecomReportsPage from './pages/WecomReportsPage'; import WecomBotLogsPage from './pages/WecomBotLogsPage'; @@ -95,6 +96,7 @@ export default function App() { } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 770f709..68c820d 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -128,6 +128,7 @@ const MENU_ITEMS: MenuProps['items'] = [ label: '企微机器人', children: [ { key: '/wecom/bots', label: '智能机器人' }, + { key: '/wecom/plugins', label: 'API 插件' }, { key: '/wecom/pushes', label: '消息推送' }, { key: '/wecom/reports', label: '报告' }, ], @@ -186,6 +187,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean { '/promo-codes': 'promo_codes', '/activity-posters': 'activity_posters', '/wecom/bots': 'wecom_bots', + '/wecom/plugins': 'wecom_bots', '/wecom/pushes': 'wecom_bots', '/wecom/reports': 'wecom_bots', 'wecom-group': 'wecom_bots', diff --git a/apps/admin-web/src/pages/WecomApiPluginsPage.tsx b/apps/admin-web/src/pages/WecomApiPluginsPage.tsx new file mode 100644 index 0000000..867619b --- /dev/null +++ b/apps/admin-web/src/pages/WecomApiPluginsPage.tsx @@ -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(); + const [filters, setFilters] = useState>({}); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); + const [rotatingId, setRotatingId] = useState(null); + const [revealed, setRevealed] = useState(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( + '/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('/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(`/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 = [ + { + title: '名称', + dataIndex: 'name', + render: (name: string, row) => ( + openEdit(row)}>{name} + ), + }, + { title: '密钥', dataIndex: 'apiKeyMasked', width: 160 }, + { + title: '权限', + dataIndex: 'permissions', + render: (perms: WecomPluginPermission[]) => + perms.map((p) => ( + + {WECOM_PLUGIN_PERMISSION_LABELS[p]} + + )), + }, + { title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '—' }, + { + title: '启用', + dataIndex: 'enabled', + width: 72, + render: (v: boolean) => (v ? : ), + }, + { title: '排序', dataIndex: 'sortOrder', width: 64 }, + { + title: '更新时间', + dataIndex: 'updatedAt', + width: 170, + render: (v: string) => fmtTime(v), + }, + { + title: '操作', + key: 'actions', + width: 220, + render: (_, row) => ( + + + void rotateKey(row.id)} + > + + + void remove(row.id)}> + + + + ), + }, + ]; + + const { columns, settingsButton, settingsModal } = useAdminListColumns( + 'wecom-api-plugins', + baseColumns, + { page, pageSize }, + ); + + return ( + <> + {settingsModal} + + 新建插件 + + } + /> + + +
+ 插件 URL: + {pluginUrl} +
+
+ OpenAPI(带该实例 Key): + {`${pluginUrl}/openapi.json`} +
+ + 授权方式 Service token / API key,Header 名 {WECOM_PLUGIN_API_KEY_HEADER} + ;第 2 步只添加本实例已勾选的工具(GET + Query)。 + + + } + /> + +
{ + setFilters(v as Record); + setPage(1); + }} + > + + + + + + + {!editing ? ( + + + + ) : ( + + + {editing.apiKeyMasked}(列表不回显明文,需要时请轮换) + + + )} + + v?.length ? Promise.resolve() : Promise.reject(new Error('请至少勾选一项')), + }, + ]} + extra={ + permissionsWatch?.length ? ( + 已选 {permissionsWatch.length} 项 + ) : null + } + > + + {WECOM_PLUGIN_PERMISSION_GROUPS.map((group) => ( +
+ {group.label} +
+ {group.permissions.map((p) => ( + + {WECOM_PLUGIN_PERMISSION_LABELS[p]} + + ))} +
+
+ ))} +
+
+ + + + + + + + + + + + + setRevealed(null)} + footer={[ + , + ]} + > + {revealed ? ( +
+

+ 实例「{revealed.name}」的 {WECOM_PLUGIN_API_KEY_HEADER},关闭后不再展示。 +

+ {revealed.apiKey} +
+ ) : null} +
+ + ); +} diff --git a/apps/admin-web/src/pages/WecomBotsPage.tsx b/apps/admin-web/src/pages/WecomBotsPage.tsx index 453acd3..86a2561 100644 --- a/apps/admin-web/src/pages/WecomBotsPage.tsx +++ b/apps/admin-web/src/pages/WecomBotsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { Alert, Avatar, @@ -26,8 +27,6 @@ import { WECOM_BOT_ROLE_DEFAULT_PERMISSIONS, WECOM_BOT_ROLE_LABELS, WECOM_BOT_ROLES, - WECOM_PLUGIN_API_KEY_HEADER, - resolveWecomPluginPublicUrl, type LlmApiConfigOptionDto, type KnowledgeBaseOptionDto, type WecomAibotRuntimeDto, @@ -410,27 +409,13 @@ export default function WecomBotsPage() { type="info" showIcon style={{ marginBottom: 16 }} - message="企微 API 插件(与上方长连接机器人独立)" + message="企微 API 插件已迁入独立页" description={ -
-
- 插件 URL: - - {resolveWecomPluginPublicUrl(window.location.hostname)} - -
-
- OpenAPI: - - {`${resolveWecomPluginPublicUrl(window.location.hostname)}/openapi.json`} - -
- - 授权方式 Service token / API key,Header 名 {WECOM_PLUGIN_API_KEY_HEADER} - ;密钥只在服务器 .env 的 WECOM_PLUGIN_API_KEY,本页不展示。须先开 - WECOM_PLUGIN_ENABLED=true。 - -
+ + 多实例、密钥与工具权限请到{' '} + 企微机器人 → API 插件 + {' '}管理,与上方长连接机器人独立。 + } /> diff --git a/deploy/git-push-dev-jacy.sh b/deploy/git-push-dev-jacy.sh new file mode 100644 index 0000000..5edce8d --- /dev/null +++ b/deploy/git-push-dev-jacy.sh @@ -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" diff --git a/deploy/git-sync-to-dev.sh b/deploy/git-sync-to-dev.sh new file mode 100644 index 0000000..36ad2da --- /dev/null +++ b/deploy/git-sync-to-dev.sh @@ -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)" diff --git a/deploy/git-sync-to-main.sh b/deploy/git-sync-to-main.sh new file mode 100644 index 0000000..740406b --- /dev/null +++ b/deploy/git-sync-to-main.sh @@ -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" diff --git a/docs/杜康好客-v3-现状对照.md b/docs/杜康好客-v3-现状对照.md index 1fee55c..fb6dd71 100644 --- a/docs/杜康好客-v3-现状对照.md +++ b/docs/杜康好客-v3-现状对照.md @@ -69,9 +69,11 @@ | 3.5.12 | [`订单大屏 BGM + HQ 中文展示 + 修复删除门店分类 + HQ 侧栏顺序`](./杜康好客-v3.5.12-开发文档.md) | 🔶 开发完成 | | 3.5.14 | [`提交订单/支付成功日志端回填 USER_MINI`](./杜康好客-v3.5.14-开发文档.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-08-30 | HQ 门店账单确认打款支持上传凭证照片(`payment_proof_urls`) | | 2026-08-26 | v3.5.14:`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` | diff --git a/docs/杜康好客-v3.5.17-开发文档.md b/docs/杜康好客-v3.5.17-开发文档.md new file mode 100644 index 0000000..ddad2df --- /dev/null +++ b/docs/杜康好客-v3.5.17-开发文档.md @@ -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 / 消息推送 / 报告行为不变 diff --git a/docs/杜康好客-v3编码手册.md b/docs/杜康好客-v3编码手册.md index 5f14968..599b9de 100644 --- a/docs/杜康好客-v3编码手册.md +++ b/docs/杜康好客-v3编码手册.md @@ -48,7 +48,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd **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`);列表手机号不脱敏。 diff --git a/docs/杜康好客-知识库.md b/docs/杜康好客-知识库.md index 2c2fbdb..f3518d8 100644 --- a/docs/杜康好客-知识库.md +++ b/docs/杜康好客-知识库.md @@ -150,7 +150,7 @@ HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑 | 能力 | 入口 | |------|------| | 智能机器人 | `/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 | | 日志 | `/logs/wecom-bots` | | C 端微信客服 | 系统设置 `CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`;小程序须已关联该企业微信客服 | diff --git a/packages/domain/src/wecom-plugin.test.ts b/packages/domain/src/wecom-plugin.test.ts index 8bb4be9..f32636a 100644 --- a/packages/domain/src/wecom-plugin.test.ts +++ b/packages/domain/src/wecom-plugin.test.ts @@ -3,7 +3,10 @@ import { maskContactPhone } from './phone'; import { clampWecomPluginPageSize, isWecomPluginEnabled, + matchWecomPluginByApiKey, + parseWecomPluginDateRange, parseWecomPluginMetricsKind, + toWecomPluginMetricsView, toWecomPluginUserView, verifyWecomPluginApiKey, 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', () => { it('requires both the switch and a non-empty key', () => { 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', () => { it('masks phone and fills empty nickname', () => { expect(toWecomPluginUserView({ userNo: 'DK1', nickname: null, phone: '13800138000' })).toEqual({ diff --git a/packages/domain/src/wecom-plugin.ts b/packages/domain/src/wecom-plugin.ts index c971313..c43c328 100644 --- a/packages/domain/src/wecom-plugin.ts +++ b/packages/domain/src/wecom-plugin.ts @@ -1,6 +1,11 @@ import { maskContactPhone } from './phone'; -import { shanghaiYmd, startOfShanghaiDay } from './shanghai-date'; -import { wecomReportPeriod, type WecomReportKind, type WecomReportPeriod } from './wecom-report'; +import { addShanghaiDays, shanghaiYmd, startOfShanghaiDay } from './shanghai-date'; +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_MAX = 10; @@ -53,6 +58,20 @@ export function isWecomPluginEnabled(env: { return env.WECOM_PLUGIN_ENABLED === 'true' && Boolean(String(env.WECOM_PLUGIN_API_KEY ?? '').trim()); } +/** 在少量启用实例中做常量时间匹配(长度不等仍会先失败) */ +export function matchWecomPluginByApiKey( + 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( kind: WecomPluginMetricsKind, now = new Date(), @@ -74,6 +93,45 @@ export function wecomPluginMetricsPeriod( 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: { userNo: string; nickname?: string | null; diff --git a/packages/shared-types/src/wecom-plugin.test.ts b/packages/shared-types/src/wecom-plugin.test.ts index cc19c3d..494d12c 100644 --- a/packages/shared-types/src/wecom-plugin.test.ts +++ b/packages/shared-types/src/wecom-plugin.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest'; import { WECOM_PLUGIN_API_KEY_HEADER, WECOM_PLUGIN_BASE_PATH, + WECOM_PLUGIN_PATH_PERMISSION, + allowedWecomPluginOpenApiPaths, + maskWecomPluginApiKey, + parseWecomPluginPermissions, resolveWecomPluginPublicUrl, + wecomPluginHasPermission, } from './wecom-plugin'; describe('resolveWecomPluginPublicUrl', () => { @@ -31,3 +36,44 @@ describe('resolveWecomPluginPublicUrl', () => { 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); + }); +}); diff --git a/packages/shared-types/src/wecom-plugin.ts b/packages/shared-types/src/wecom-plugin.ts index b04d91b..f79af8c 100644 --- a/packages/shared-types/src/wecom-plugin.ts +++ b/packages/shared-types/src/wecom-plugin.ts @@ -12,6 +12,188 @@ export const WECOM_PLUGIN_METRICS_KINDS = ['today', 'daily', 'weekly', 'monthly' 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 = { + '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 = { + '/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 = { + '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(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(不含密钥) */ export function resolveWecomPluginPublicUrl(hostname: string): string { const host = String(hostname || '').toLowerCase(); diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index 31b02ee..b06f78c 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -67,10 +67,6 @@ WX_MINI_MSG_AES_KEY= # 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建) WECOM_AIBOT_ENABLED=false -# 企微「API 插件」只读数据面(与长连接 Bot 独立;密钥勿提交) -WECOM_PLUGIN_ENABLED=false -# WECOM_PLUGIN_API_KEY= - # 运营告警 Webhook(已废弃运行时读取,仅 seed 一次性导入到 HQ「消息推送」) # 配置后执行 pnpm prisma:seed-wecom-push 或 API 启动时自动 ensureDefaults # WECOM_ALERT_ENABLED=false diff --git a/server/dukang-api/.env.production.example b/server/dukang-api/.env.production.example index 5f80bd6..fb13bb8 100644 --- a/server/dukang-api/.env.production.example +++ b/server/dukang-api/.env.production.example @@ -54,10 +54,6 @@ WX_MINI_MSG_AES_KEY= # 企业微信机器人总开关(实例在 HQ 企微机器人模块维护) WECOM_AIBOT_ENABLED=false -# 企微 API 插件只读数据面(与长连接 Bot 独立) -WECOM_PLUGIN_ENABLED=false -# WECOM_PLUGIN_API_KEY= - # 运营告警:企业微信群机器人 Webhook # 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」) # WECOM_ALERT_ENABLED=false diff --git a/server/dukang-api/.env.staging.example b/server/dukang-api/.env.staging.example index 0ccadf1..92c4fba 100644 --- a/server/dukang-api/.env.staging.example +++ b/server/dukang-api/.env.staging.example @@ -54,10 +54,6 @@ WX_MINI_MSG_AES_KEY= WECOM_AIBOT_ENABLED=false -# 企微 API 插件(测试环境单独一把 Key) -WECOM_PLUGIN_ENABLED=false -# WECOM_PLUGIN_API_KEY= - # 运营告警:企业微信群机器人 Webhook # 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」) # WECOM_ALERT_ENABLED=false diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index af68495..d9def19 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -21,6 +21,7 @@ "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-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: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", diff --git a/server/dukang-api/prisma/migrate-wecom-api-plugin.ts b/server/dukang-api/prisma/migrate-wecom-api-plugin.ts new file mode 100644 index 0000000..7704667 --- /dev/null +++ b/server/dukang-api/prisma/migrate-wecom-api-plugin.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(); + }); diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 76ddcf8..5a68853 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -493,6 +493,23 @@ model WecomBot { @@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 { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt diff --git a/server/dukang-api/scripts/gen-staging-env-from-prod.cjs b/server/dukang-api/scripts/gen-staging-env-from-prod.cjs index cb5f51f..32d361d 100644 --- a/server/dukang-api/scripts/gen-staging-env-from-prod.cjs +++ b/server/dukang-api/scripts/gen-staging-env-from-prod.cjs @@ -51,7 +51,6 @@ const fixed = { WX_PAY_NOTIFY_URL: 'https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay', OSS_UPLOAD_PREFIX: 'staging/uploads', WECOM_AIBOT_ENABLED: 'false', - WECOM_PLUGIN_ENABLED: 'false', }; const preferFromProd = [ diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index ff2afb2..050e4f5 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -95,6 +95,10 @@ export const HqOperationAction = { WECOM_MESSAGE_PUSH_UPDATE: 'WECOM_MESSAGE_PUSH_UPDATE', WECOM_MESSAGE_PUSH_DELETE: 'WECOM_MESSAGE_PUSH_DELETE', 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_SEND: 'WECOM_REPORT_SEND', LLM_CONFIG_CREATE: 'LLM_CONFIG_CREATE', @@ -232,6 +236,10 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.WECOM_MESSAGE_PUSH_UPDATE]: '编辑企微消息推送', [HqOperationAction.WECOM_MESSAGE_PUSH_DELETE]: '删除企微消息推送', [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_SEND]: '发送企微经营报告', [HqOperationAction.LLM_CONFIG_CREATE]: '创建语言模型配置', diff --git a/server/dukang-api/src/integrations/wecom/wecom-bot-audit.service.ts b/server/dukang-api/src/integrations/wecom/wecom-bot-audit.service.ts index 4517149..0470b67 100644 --- a/server/dukang-api/src/integrations/wecom/wecom-bot-audit.service.ts +++ b/server/dukang-api/src/integrations/wecom/wecom-bot-audit.service.ts @@ -9,7 +9,7 @@ export type WecomBotAuditContext = { bot: WecomBotRuntimeConfig; wecomUserId: string; action: string; - permission?: WecomBotPermission | null; + permission?: WecomBotPermission | string | null; inputSummary?: string | null; }; diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin-auth.service.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin-auth.service.ts new file mode 100644 index 0000000..6bfa789 --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin-auth.service.ts @@ -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 { + 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'); + } +} diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin-query.service.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin-query.service.ts index 156ba46..0bf2259 100644 --- a/server/dukang-api/src/integrations/wecom/wecom-plugin-query.service.ts +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin-query.service.ts @@ -1,19 +1,29 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { clampWecomPluginPage, clampWecomPluginPageSize, maskContactPhone, MOBILE_PHONE_RE, parseWecomPluginMetricsKind, + parseWecomPluginDateRange, + toWecomPluginMetricsView, toWecomPluginUserView, wecomPluginMetricsPeriod, type WecomReportStats, } 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 { PrismaService } from '../../common/prisma/prisma.module'; import { PromoCodeService } from '../../modules/promo/promo-code.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 { if (v == null) return 0; @@ -35,22 +45,40 @@ export class WecomPluginQueryService { private readonly audit: WecomBotAuditService, ) {} - info() { + info(plugin: WecomPluginRuntime) { return { - name: '杜康好客运营查询', - description: '企微智能机器人只读 API 插件。查询订单、用户、门店、核销、推广码与经营指标。', + name: plugin.name, + description: '企微智能机器人只读 API 插件。查询订单、用户、门店经营、核销、推广码、经营指标、审核对照与合伙人关联数据。', 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 take = clampWecomPluginPageSize(pageSize); const skip = (clampWecomPluginPage(page) - 1) * take; return this.audit.run( { - bot: WECOM_PLUGIN_AUDIT_BOT, + bot: wecomPluginAuditBot(plugin), wecomUserId, action: 'plugin.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 take = clampWecomPluginPageSize(pageSize); const skip = (clampWecomPluginPage(page) - 1) * take; return this.audit.run( { - bot: WECOM_PLUGIN_AUDIT_BOT, + bot: wecomPluginAuditBot(plugin), wecomUserId, action: 'plugin.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 take = clampWecomPluginPageSize(pageSize); const skip = (clampWecomPluginPage(page) - 1) * take; return this.audit.run( { - bot: WECOM_PLUGIN_AUDIT_BOT, + bot: wecomPluginAuditBot(plugin), wecomUserId, action: 'plugin.store.read', permission: 'store.read', @@ -173,39 +215,71 @@ export class WecomPluginQueryService { skip, take, select: { + id: true, name: true, status: true, + auditStatus: true, cityName: true, district: true, address: true, contactPhone: true, phone: true, + rating: true, + createdAt: true, + partnerAccount: { select: { id: true, name: true, companyName: 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 { total, items: rows.map((s) => ({ + id: s.id.toString(), name: s.name, status: s.status, + auditStatus: s.auditStatus, cityName: s.cityName, district: s.district, address: s.address, 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 take = clampWecomPluginPageSize(pageSize); const skip = (clampWecomPluginPage(page) - 1) * take; return this.audit.run( { - bot: WECOM_PLUGIN_AUDIT_BOT, + bot: wecomPluginAuditBot(plugin), wecomUserId, action: 'plugin.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 take = clampWecomPluginPageSize(pageSize); const skip = (clampWecomPluginPage(page) - 1) * take; return this.audit.run( { - bot: WECOM_PLUGIN_AUDIT_BOT, + bot: wecomPluginAuditBot(plugin), wecomUserId, action: 'plugin.promo.read', + permission: 'promo.read', inputSummary: keyword, }, 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); return this.audit.run( { - bot: WECOM_PLUGIN_AUDIT_BOT, + bot: wecomPluginAuditBot(plugin), wecomUserId, action: 'plugin.promo.stats', + permission: 'promo.read', inputSummary: keyword, }, 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); if (!kind) { throw new BadRequestException('kind 须为 today | daily | weekly | monthly'); } return this.audit.run( { - bot: WECOM_PLUGIN_AUDIT_BOT, + bot: wecomPluginAuditBot(plugin), wecomUserId, action: 'plugin.metrics.read', + permission: 'metrics.read', inputSummary: kind, }, async () => { @@ -336,7 +422,7 @@ export class WecomPluginQueryService { rangeLabel: period.rangeLabel, incrementLabel: period.incrementLabel, periodKey: period.periodKey, - stats, + stats: toWecomPluginMetricsView(stats), }; }, ); @@ -415,4 +501,555 @@ export class WecomPluginQueryService { 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; + const proposed = (row.proposedSnapshot || {}) as Record; + 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 } }); + } } diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin.constants.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin.constants.ts deleted file mode 100644 index acf022d..0000000 --- a/server/dukang-api/src/integrations/wecom/wecom-plugin.constants.ts +++ /dev/null @@ -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, -}; diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin.controller.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin.controller.ts index 2ab59b2..21a4b5d 100644 --- a/server/dukang-api/src/integrations/wecom/wecom-plugin.controller.ts +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin.controller.ts @@ -2,7 +2,8 @@ import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; import { WecomPluginGuard } from './wecom-plugin.guard'; 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') @UseGuards(WecomPluginGuard) @@ -10,73 +11,212 @@ export class WecomPluginController { constructor(private readonly query: WecomPluginQueryService) {} @Get() - info() { - return this.query.info(); + info(@CurrentWecomPlugin() plugin: WecomPluginRuntime) { + return this.query.info(plugin); } @Get('openapi.json') - openapi() { - return WECOM_PLUGIN_OPENAPI; + openapi(@CurrentWecomPlugin() plugin: WecomPluginRuntime) { + return this.query.openapi(plugin); } @Get('orders') orders( + @CurrentWecomPlugin() plugin: WecomPluginRuntime, @Req() req: Request, @Query('q') q?: string, @Query('page') page?: 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') users( + @CurrentWecomPlugin() plugin: WecomPluginRuntime, @Req() req: Request, @Query('q') q?: string, @Query('page') page?: 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') stores( + @CurrentWecomPlugin() plugin: WecomPluginRuntime, @Req() req: Request, @Query('q') q?: string, @Query('page') page?: 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') redeems( + @CurrentWecomPlugin() plugin: WecomPluginRuntime, @Req() req: Request, @Query('q') q?: string, @Query('page') page?: 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') promoCodes( + @CurrentWecomPlugin() plugin: WecomPluginRuntime, @Req() req: Request, @Query('q') q?: string, @Query('page') page?: 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') - promoStats(@Req() req: Request, @Param('code') code: string) { - return this.query.queryPromoCodeStats(pluginCaller(req), code); + promoStats( + @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') - metrics(@Req() req: Request, @Query('kind') kind?: string) { - return this.query.queryMetrics(pluginCaller(req), kind); + metrics( + @CurrentWecomPlugin() plugin: WecomPluginRuntime, + @Req() req: Request, + @Query('kind') kind?: string, + ) { + return this.query.queryMetrics(plugin, pluginCaller(req), kind); } } diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin.decorators.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin.decorators.ts new file mode 100644 index 0000000..03d60f0 --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin.decorators.ts @@ -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; + }, +); diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin.guard.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin.guard.ts index 92505d0..71ac6ab 100644 --- a/server/dukang-api/src/integrations/wecom/wecom-plugin.guard.ts +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin.guard.ts @@ -4,19 +4,24 @@ import { Injectable, UnauthorizedException, } from '@nestjs/common'; -import { isWecomPluginEnabled, verifyWecomPluginApiKey } from '@dukang/domain'; +import { WecomPluginAuthService } from './wecom-plugin-auth.service'; +import type { WecomPluginRuntime } from './wecom-plugin.types'; @Injectable() export class WecomPluginGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean { - if (!isWecomPluginEnabled(process.env)) { - throw new UnauthorizedException('Unauthorized'); - } - const req = context.switchToHttp().getRequest<{ headers: Record }>(); + constructor(private readonly auth: WecomPluginAuthService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest<{ + headers: Record; + wecomPlugin?: WecomPluginRuntime; + }>(); 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'); } + req.wecomPlugin = plugin; return true; } } diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin.openapi.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin.openapi.ts index 8b73957..a9ccaa6 100644 --- a/server/dukang-api/src/integrations/wecom/wecom-plugin.openapi.ts +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin.openapi.ts @@ -1,3 +1,6 @@ +import type { WecomPluginPermission } from '@dukang/shared-types'; +import { allowedWecomPluginOpenApiPaths } from '@dukang/shared-types'; + const envelope = (dataSchema: Record) => ({ type: 'object', 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 = { description: '缺少或错误的 X-Api-Key,或插件未启用', 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 = {}; + 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}。 */ export const WECOM_PLUGIN_OPENAPI = { openapi: '3.0.3', @@ -101,7 +142,141 @@ export const WECOM_PLUGIN_OPENAPI = { paths: { '/orders': listPath('查询订单', '按订单号模糊查询', '订单号,如 DK20260903xxxx'), '/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('查询核销', '按核销单号或门店名查询', '核销单号或门店名'), '/promo-codes': listPath('查询推广码', '按推广码 code 或名称查询', '推广码或名称'), '/promo-codes/{code}/stats': { @@ -130,7 +305,7 @@ export const WECOM_PLUGIN_OPENAPI = { get: { summary: '经营指标', description: - 'today=今日截至当前;daily/weekly/monthly 与企微经营报告同一口径(用户有效未合并,订单金额=已付 payAmount)。', + 'today=今日截至当前;daily/weekly/monthly 与企微经营报告同一口径。stats 含 users/partners/stores/orders 存量与增量;storesIncrement 与 newStores 均为新增门店数。', operationId: '查询经营指标', parameters: [ { diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin.types.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin.types.ts new file mode 100644 index 0000000..6c62b1c --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin.types.ts @@ -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, + }; +} diff --git a/server/dukang-api/src/integrations/wecom/wecom.module.ts b/server/dukang-api/src/integrations/wecom/wecom.module.ts index f10ee54..74ae012 100644 --- a/server/dukang-api/src/integrations/wecom/wecom.module.ts +++ b/server/dukang-api/src/integrations/wecom/wecom.module.ts @@ -11,6 +11,7 @@ import { WecomBotAiService } from './wecom-bot-ai.service'; import { WecomBotAuditService } from './wecom-bot-audit.service'; import { WecomBotCapabilityService } from './wecom-bot-capability.service'; import { WecomBotSessionService } from './wecom-bot-session.service'; +import { WecomPluginAuthService } from './wecom-plugin-auth.service'; import { WecomPluginController } from './wecom-plugin.controller'; import { WecomPluginGuard } from './wecom-plugin.guard'; import { WecomPluginQueryService } from './wecom-plugin-query.service'; @@ -33,6 +34,7 @@ import { WecomPluginQueryService } from './wecom-plugin-query.service'; WecomBotActionsService, WecomBotAiService, WecomAibotService, + WecomPluginAuthService, WecomPluginGuard, WecomPluginQueryService, ], diff --git a/server/dukang-api/src/modules/ops/admin-wecom-api-plugins.controller.ts b/server/dukang-api/src/modules/ops/admin-wecom-api-plugins.controller.ts new file mode 100644 index 0000000..c3e8f97 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-wecom-api-plugins.controller.ts @@ -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)); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-wecom-api-plugins.service.ts b/server/dukang-api/src/modules/ops/admin-wecom-api-plugins.service.ts new file mode 100644 index 0000000..3fa9118 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-wecom-api-plugins.service.ts @@ -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 { + return this.toDto(await this.mustGet(id)); + } + + async create(dto: CreateWecomApiPluginRequest): Promise { + 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 { + 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 { + 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')}`; +} diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index 744fdee..7e8e8bf 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -69,6 +69,8 @@ import { AdminWecomBotsController } from './admin-wecom-bots.controller'; import { AdminWecomBotsService } from './admin-wecom-bots.service'; import { AdminWecomMessagePushesController } from './admin-wecom-message-pushes.controller'; 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 { AdminWecomReportsService } from './admin-wecom-reports.service'; import { AdminWecomPushTemplatesController } from './admin-wecom-push-templates.controller'; @@ -130,6 +132,7 @@ import { PartnerActivityPostersController } from './partner-activity-posters.con AdminSystemConfigController, AdminWecomBotsController, AdminWecomMessagePushesController, + AdminWecomApiPluginsController, AdminWecomReportsController, AdminWecomPushTemplatesController, AdminWecomBotLogsController, @@ -169,6 +172,7 @@ import { PartnerActivityPostersController } from './partner-activity-posters.con AdminDeployService, AdminWecomBotsService, AdminWecomMessagePushesService, + AdminWecomApiPluginsService, AdminWecomReportsService, AdminWecomBotLogsService, AdminLlmConfigsService,