diff --git a/apps/admin-web/src/pages/WecomBotsPage.tsx b/apps/admin-web/src/pages/WecomBotsPage.tsx index 2d4342f..453acd3 100644 --- a/apps/admin-web/src/pages/WecomBotsPage.tsx +++ b/apps/admin-web/src/pages/WecomBotsPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { + Alert, Avatar, Button, Checkbox, @@ -25,6 +26,8 @@ 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, @@ -403,6 +406,34 @@ export default function WecomBotsPage() { + +
+ 插件 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。 + + + } + /> +
**2026-09-03** · integrations/wecom · admin-web · domain · shared-types +> **主题**:企微智能机器人 **API 插件**只读数据面 + +--- + +## 1. 版本目标 + +企微后台「添加 API 插件」对接本系统:企微托管大模型 HTTP 调公网接口拉数。与 HQ「企微机器人」长连接 Bot **独立**,不改指令/审批/短信验身。 + +**不做**:写操作、明文手机/地址、把 `/admin/*` JWT 接口暴露给企微。 + +--- + +## 2. 与长连接 Bot 的分工 + +| | 长连接智能机器人 | API 插件 | +|--|------------------|----------| +| 对话 | 我们收消息并回复 | 企微自带模型组织回复 | +| 入口 | HQ 企微机器人 + `@wecom/aibot-node-sdk` | 企微「添加 API 插件」 | +| 鉴权 | BotID / Secret | Header `X-Api-Key` | +| 能力 | 指令 + 工单/审批等 | 第一期只读查询 | + +建议:客服/技术支持继续走长连接;另建一只「运营查询」机器人只挂本插件。 + +--- + +## 3. 环境变量 + +``` +WECOM_PLUGIN_ENABLED=true +WECOM_PLUGIN_API_KEY=<随机长密钥> +``` + +只放 `.env` / `.env.staging` / `.env.production`,不进 HQ 系统设置、不进 Git。 + +--- + +## 4. 接口 + +前缀:`/api/v1/wecom/plugin` +鉴权:Header `X-Api-Key`(未启用 / 无 Key / 错 Key 一律 401) +响应:`{ code, message, data }`(`GET .../openapi.json` **除外**,原样 OpenAPI 3.0) +列表 `pageSize` 默认 5、最大 10。手机号 `maskContactPhone`。 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/` | 插件说明 | +| GET | `/openapi.json` | 供第 2 步导入工具 | +| GET | `/orders?q=` | 订单号 | +| GET | `/users?q=` | 用户号或 11 位手机 | +| GET | `/stores?q=` | 门店名 | +| GET | `/redeems?q=` | 核销单号或门店名 | +| GET | `/promo-codes?q=` | 推广码 / 名称 | +| GET | `/promo-codes/:code/stats` | 推广码统计 | +| GET | `/metrics?kind=` | `today` \| `daily` \| `weekly` \| `monthly` | + +经营指标口径与 v3.5.15 报告一致:用户=有效未合并;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。`today` 期末为当前时刻。 + +审计:`log_wecom_bot.botKey=plugin`。 + +--- + +## 5. 企微表单(上线后) + +| 字段 | 值 | +|------|-----| +| 插件 URL | 生产 `https://api.dukanghaoke.com/api/v1/wecom/plugin`;测试 `https://api-test.dukanghaoke.com/api/v1/wecom/plugin` | +| 授权 | Service token / API key | +| 位置 | Header | +| Parameter name | `X-Api-Key` | +| Service token | 与 `WECOM_PLUGIN_API_KEY` 相同 | +| 第 2 步导入 | `…/wecom/plugin/openapi.json`(同一把 Key) | + +--- + +## 6. 联调清单(staging curl) + +先在 `api-test` 的 `.env.staging` 打开开关并写入 Key,重启 `dukang-api`。 + +```bash +BASE=https://api-test.dukanghaoke.com/api/v1/wecom/plugin +KEY='' + +# 无 Key → 401 +curl -sS -o /dev/null -w '%{http_code}\n' "$BASE/metrics" + +# 错 Key → 401 +curl -sS -o /dev/null -w '%{http_code}\n' -H "X-Api-Key: wrong" "$BASE/metrics" + +# 经营指标 / OpenAPI / 订单 / 门店 / 推广码 +curl -sS -H "X-Api-Key: $KEY" "$BASE/metrics?kind=today" +curl -sS -H "X-Api-Key: $KEY" "$BASE/openapi.json" | head -c 200 +curl -sS -H "X-Api-Key: $KEY" "$BASE/orders?q=DK" +curl -sS -H "X-Api-Key: $KEY" "$BASE/stores?q=店" +curl -sS -H "X-Api-Key: $KEY" "$BASE/promo-codes?q=DK" +``` + +企微:导入 OpenAPI → 白名单会话用自然语言问订单/门店 → HQ「企微机器人 → 日志」出现 `plugin`。 + +--- + +## 7. 变更面 + +| 层 | 路径 | +|----|------| +| domain | `wecom-plugin.ts` | +| shared-types | `wecom-plugin.ts` | +| API | `integrations/wecom/wecom-plugin.*`;`WecomModule` 注册 Controller | +| HQ | `WecomBotsPage.tsx` 插件 URL / Header 提示(不展示 Key) | +| env | `.env*.example`:`WECOM_PLUGIN_ENABLED` / `WECOM_PLUGIN_API_KEY` | + +--- + +## 8. 验收 + +- [ ] 无 Key / 错 Key → 401 +- [ ] curl 带 Key 查订单 / 门店 / 推广码 / 今日指标,`{ code:0, data }` 且手机脱敏 +- [ ] `openapi.json` 为 OpenAPI 文档(无信封) +- [ ] 企微第 2 步可导入工具;白名单会话能问到真实数据 +- [ ] HQ 企微机器人日志可见 `plugin` +- [ ] 现有长连接 Bot 行为不变 diff --git a/docs/杜康好客-v3编码手册.md b/docs/杜康好客-v3编码手册.md index ef34b9f..5f14968 100644 --- a/docs/杜康好客-v3编码手册.md +++ b/docs/杜康好客-v3编码手册.md @@ -48,6 +48,8 @@ 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`。 + **HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。 **C 端(v3.5.10)**:门店详情无顶栏分享按钮。同城送提示取开城仓库绑定承运商的 `delivery_hint_html`(`GET /catalog/local-deliveries`,按收货市是否开城);空则回退「同城配送,预计24小时内送到」。在线客服优先 `wx.openCustomerServiceChat`(`CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`);未配 CorpID 回退小程序原生客服。 diff --git a/docs/杜康好客-知识库.md b/docs/杜康好客-知识库.md index a258018..2c2fbdb 100644 --- a/docs/杜康好客-知识库.md +++ b/docs/杜康好客-知识库.md @@ -150,6 +150,7 @@ HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑 | 能力 | 入口 | |------|------| | 智能机器人 | `/wecom/bots` 长连接指令 | +| API 插件 | `/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/index.ts b/packages/domain/src/index.ts index 7d47a42..49ea89a 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -413,4 +413,5 @@ export * from './shanghai-date'; export * from './dashboard-period'; export * from './dashboard-series'; export * from './wecom-report'; +export * from './wecom-plugin'; export * from './shipping-address'; diff --git a/packages/domain/src/wecom-plugin.test.ts b/packages/domain/src/wecom-plugin.test.ts new file mode 100644 index 0000000..8bb4be9 --- /dev/null +++ b/packages/domain/src/wecom-plugin.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { maskContactPhone } from './phone'; +import { + clampWecomPluginPageSize, + isWecomPluginEnabled, + parseWecomPluginMetricsKind, + toWecomPluginUserView, + verifyWecomPluginApiKey, + wecomPluginMetricsPeriod, +} from './wecom-plugin'; + +describe('verifyWecomPluginApiKey', () => { + it('accepts an exact match', () => { + expect(verifyWecomPluginApiKey('secret-token', 'secret-token')).toBe(true); + }); + + it('rejects wrong, empty, or missing keys', () => { + expect(verifyWecomPluginApiKey('secret-token', 'other-token')).toBe(false); + expect(verifyWecomPluginApiKey('secret-token', 'secret-toke')).toBe(false); + expect(verifyWecomPluginApiKey('', 'secret-token')).toBe(false); + expect(verifyWecomPluginApiKey('secret-token', '')).toBe(false); + expect(verifyWecomPluginApiKey('secret-token', null)).toBe(false); + }); +}); + +describe('isWecomPluginEnabled', () => { + it('requires both the switch and a non-empty key', () => { + expect(isWecomPluginEnabled({ WECOM_PLUGIN_ENABLED: 'true', WECOM_PLUGIN_API_KEY: 'k' })).toBe( + true, + ); + expect(isWecomPluginEnabled({ WECOM_PLUGIN_ENABLED: 'true', WECOM_PLUGIN_API_KEY: '' })).toBe( + false, + ); + expect(isWecomPluginEnabled({ WECOM_PLUGIN_ENABLED: 'false', WECOM_PLUGIN_API_KEY: 'k' })).toBe( + false, + ); + }); +}); + +describe('clampWecomPluginPageSize / parseWecomPluginMetricsKind', () => { + it('defaults and caps pageSize at 10', () => { + expect(clampWecomPluginPageSize(undefined)).toBe(5); + expect(clampWecomPluginPageSize('3')).toBe(3); + expect(clampWecomPluginPageSize(99)).toBe(10); + expect(clampWecomPluginPageSize(0)).toBe(5); + }); + + it('parses metrics kind, defaulting empty to today', () => { + expect(parseWecomPluginMetricsKind(undefined)).toBe('today'); + expect(parseWecomPluginMetricsKind('weekly')).toBe('weekly'); + expect(parseWecomPluginMetricsKind('nope')).toBeNull(); + }); +}); + +describe('toWecomPluginUserView', () => { + it('masks phone and fills empty nickname', () => { + expect(toWecomPluginUserView({ userNo: 'DK1', nickname: null, phone: '13800138000' })).toEqual({ + userNo: 'DK1', + nickname: '—', + phone: '138****8000', + }); + expect(maskContactPhone('13800138000')).toBe('138****8000'); + }); +}); + +describe('wecomPluginMetricsPeriod', () => { + it('today starts at Shanghai midnight and ends at now', () => { + const now = new Date('2026-09-03T21:15:00+08:00'); + const p = wecomPluginMetricsPeriod('today', now); + expect(p.kind).toBe('today'); + expect(p.periodKey).toBe('2026-09-03'); + expect(p.start.toISOString()).toBe(new Date('2026-09-03T00:00:00+08:00').toISOString()); + expect(p.endExclusive.getTime()).toBe(now.getTime()); + }); +}); diff --git a/packages/domain/src/wecom-plugin.ts b/packages/domain/src/wecom-plugin.ts new file mode 100644 index 0000000..c971313 --- /dev/null +++ b/packages/domain/src/wecom-plugin.ts @@ -0,0 +1,87 @@ +import { maskContactPhone } from './phone'; +import { shanghaiYmd, startOfShanghaiDay } from './shanghai-date'; +import { wecomReportPeriod, type WecomReportKind, type WecomReportPeriod } from './wecom-report'; + +export const WECOM_PLUGIN_PAGE_SIZE_DEFAULT = 5; +export const WECOM_PLUGIN_PAGE_SIZE_MAX = 10; + +export const WECOM_PLUGIN_METRICS_KINDS = ['today', 'daily', 'weekly', 'monthly'] as const; +export type WecomPluginMetricsKind = (typeof WECOM_PLUGIN_METRICS_KINDS)[number]; + +export type WecomPluginMetricsPeriod = Omit & { + kind: WecomPluginMetricsKind; +}; + +export function isWecomPluginMetricsKind(v: string): v is WecomPluginMetricsKind { + return (WECOM_PLUGIN_METRICS_KINDS as readonly string[]).includes(v); +} + +export function parseWecomPluginMetricsKind(raw?: string | null): WecomPluginMetricsKind | null { + const v = String(raw ?? '').trim().toLowerCase(); + if (!v) return 'today'; + return isWecomPluginMetricsKind(v) ? v : null; +} + +export function clampWecomPluginPageSize(raw?: string | number | null): number { + const n = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isFinite(n) || n <= 0) return WECOM_PLUGIN_PAGE_SIZE_DEFAULT; + return Math.min(WECOM_PLUGIN_PAGE_SIZE_MAX, Math.max(1, Math.floor(n))); +} + +export function clampWecomPluginPage(raw?: string | number | null): number { + const n = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isFinite(n) || n <= 0) return 1; + return Math.min(100, Math.floor(n)); +} + +/** 长度必须相同后再逐字符 XOR,避免短密码走快速失败路径时的明显差异(仍非密码学级) */ +export function verifyWecomPluginApiKey(provided: string, expected?: string | null): boolean { + const exp = String(expected ?? ''); + const got = String(provided ?? ''); + if (!exp || !got || exp.length !== got.length) return false; + let diff = 0; + for (let i = 0; i < exp.length; i++) { + diff |= exp.charCodeAt(i) ^ got.charCodeAt(i); + } + return diff === 0; +} + +export function isWecomPluginEnabled(env: { + WECOM_PLUGIN_ENABLED?: string; + WECOM_PLUGIN_API_KEY?: string; +}): boolean { + return env.WECOM_PLUGIN_ENABLED === 'true' && Boolean(String(env.WECOM_PLUGIN_API_KEY ?? '').trim()); +} + +export function wecomPluginMetricsPeriod( + kind: WecomPluginMetricsKind, + now = new Date(), +): WecomPluginMetricsPeriod { + if (kind === 'today') { + const start = startOfShanghaiDay(now); + const ymd = shanghaiYmd(start); + return { + kind, + start, + endExclusive: now, + periodKey: ymd, + title: `今日(${ymd})`, + rangeLabel: ymd, + incrementLabel: '今日新增', + }; + } + const period = wecomReportPeriod(kind as WecomReportKind, now); + return { ...period, kind }; +} + +export function toWecomPluginUserView(user: { + userNo: string; + nickname?: string | null; + phone?: string | null; +}): { userNo: string; nickname: string; phone: string } { + return { + userNo: user.userNo, + nickname: user.nickname?.trim() || '—', + phone: maskContactPhone(user.phone), + }; +} diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index d78ea2b..2a544dd 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -29,6 +29,7 @@ export * from './city-warehouse'; export * from './fulfillment-provider'; export * from './system-config'; export * from './wecom-bot'; +export * from './wecom-plugin'; export * from './wecom-message-push'; export * from './wecom-report'; export * from './llm-config'; diff --git a/packages/shared-types/src/wecom-plugin.test.ts b/packages/shared-types/src/wecom-plugin.test.ts new file mode 100644 index 0000000..cc19c3d --- /dev/null +++ b/packages/shared-types/src/wecom-plugin.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { + WECOM_PLUGIN_API_KEY_HEADER, + WECOM_PLUGIN_BASE_PATH, + resolveWecomPluginPublicUrl, +} from './wecom-plugin'; + +describe('resolveWecomPluginPublicUrl', () => { + it('maps HQ production host to api.dukanghaoke.com', () => { + expect(resolveWecomPluginPublicUrl('admin.dukanghaoke.com')).toBe( + `https://api.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`, + ); + }); + + it('maps staging hosts to api-test', () => { + expect(resolveWecomPluginPublicUrl('admin-test.dukanghaoke.com')).toBe( + `https://api-test.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`, + ); + expect(resolveWecomPluginPublicUrl('api-test.dukanghaoke.com')).toBe( + `https://api-test.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`, + ); + }); + + it('falls back to local API for unknown hosts', () => { + expect(resolveWecomPluginPublicUrl('localhost')).toBe( + `http://localhost:3010${WECOM_PLUGIN_BASE_PATH}`, + ); + }); + + it('uses X-Api-Key as the plugin auth header', () => { + expect(WECOM_PLUGIN_API_KEY_HEADER).toBe('X-Api-Key'); + }); +}); diff --git a/packages/shared-types/src/wecom-plugin.ts b/packages/shared-types/src/wecom-plugin.ts new file mode 100644 index 0000000..b04d91b --- /dev/null +++ b/packages/shared-types/src/wecom-plugin.ts @@ -0,0 +1,25 @@ +/** 企微智能机器人 API 插件(只读数据面,与长连接 Bot 独立) */ + +export const WECOM_PLUGIN_API_KEY_HEADER = 'X-Api-Key'; + +export const WECOM_PLUGIN_BASE_PATH = '/api/v1/wecom/plugin'; + +export const WECOM_PLUGIN_PAGE_SIZE_DEFAULT = 5; + +export const WECOM_PLUGIN_PAGE_SIZE_MAX = 10; + +export const WECOM_PLUGIN_METRICS_KINDS = ['today', 'daily', 'weekly', 'monthly'] as const; + +export type WecomPluginMetricsKind = (typeof WECOM_PLUGIN_METRICS_KINDS)[number]; + +/** 按 HQ 当前域名推断插件公网 Base URL(不含密钥) */ +export function resolveWecomPluginPublicUrl(hostname: string): string { + const host = String(hostname || '').toLowerCase(); + if (host === 'admin.dukanghaoke.com' || host === 'api.dukanghaoke.com') { + return `https://api.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`; + } + if (host.includes('dukanghaoke.com') && host.includes('test')) { + return `https://api-test.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`; + } + return `http://localhost:3010${WECOM_PLUGIN_BASE_PATH}`; +} diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index b06f78c..31b02ee 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -67,6 +67,10 @@ 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 fb13bb8..5f80bd6 100644 --- a/server/dukang-api/.env.production.example +++ b/server/dukang-api/.env.production.example @@ -54,6 +54,10 @@ 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 92c4fba..0ccadf1 100644 --- a/server/dukang-api/.env.staging.example +++ b/server/dukang-api/.env.staging.example @@ -54,6 +54,10 @@ 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/scripts/gen-staging-env-from-prod.cjs b/server/dukang-api/scripts/gen-staging-env-from-prod.cjs index 32d361d..cb5f51f 100644 --- a/server/dukang-api/scripts/gen-staging-env-from-prod.cjs +++ b/server/dukang-api/scripts/gen-staging-env-from-prod.cjs @@ -51,6 +51,7 @@ 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/interceptors/response.interceptor.ts b/server/dukang-api/src/common/interceptors/response.interceptor.ts index 5cf3956..2d644b0 100644 --- a/server/dukang-api/src/common/interceptors/response.interceptor.ts +++ b/server/dukang-api/src/common/interceptors/response.interceptor.ts @@ -6,10 +6,19 @@ import { } from '@nestjs/common'; import { Observable, map } from 'rxjs'; +function skipResponseWrap(url?: string): boolean { + const path = (url || '').split('?')[0]; + return path.endsWith('/wecom/plugin/openapi.json'); +} + @Injectable() export class ResponseInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest<{ originalUrl?: string; url?: string }>(); const res = context.switchToHttp().getResponse<{ headersSent?: boolean }>(); + if (skipResponseWrap(req.originalUrl || req.url)) { + return next.handle(); + } return next.handle().pipe( map((data) => { if (res.headersSent) return data; @@ -22,3 +31,4 @@ export class ResponseInterceptor implements NestInterceptor { ); } } + 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 new file mode 100644 index 0000000..156ba46 --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin-query.service.ts @@ -0,0 +1,418 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { + clampWecomPluginPage, + clampWecomPluginPageSize, + maskContactPhone, + MOBILE_PHONE_RE, + parseWecomPluginMetricsKind, + toWecomPluginUserView, + wecomPluginMetricsPeriod, + type WecomReportStats, +} from '@dukang/domain'; +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'; + +function asNumber(v: Prisma.Decimal | number | null | undefined): number { + if (v == null) return 0; + if (typeof v === 'number') return Number.isFinite(v) ? v : 0; + return Number(v); +} + +function requireQuery(q?: string): string { + const v = String(q ?? '').trim(); + if (!v) throw new BadRequestException('请提供查询关键词 q'); + return v; +} + +@Injectable() +export class WecomPluginQueryService { + constructor( + private readonly prisma: PrismaService, + private readonly promo: PromoCodeService, + private readonly audit: WecomBotAuditService, + ) {} + + info() { + return { + name: '杜康好客运营查询', + description: '企微智能机器人只读 API 插件。查询订单、用户、门店、核销、推广码与经营指标。', + auth: { header: 'X-Api-Key' }, + tools: ['orders', 'users', 'stores', 'redeems', 'promo-codes', 'metrics'], + }; + } + + queryOrders(wecomUserId: string, q?: string, page?: string, pageSize?: string) { + const keyword = requireQuery(q); + const take = clampWecomPluginPageSize(pageSize); + const skip = (clampWecomPluginPage(page) - 1) * take; + return this.audit.run( + { + bot: WECOM_PLUGIN_AUDIT_BOT, + wecomUserId, + action: 'plugin.order.read', + permission: 'order.read', + inputSummary: keyword, + }, + async () => { + const [items, total] = await Promise.all([ + this.prisma.order.findMany({ + where: { orderNo: { contains: keyword } }, + orderBy: { createdAt: 'desc' }, + skip, + take, + include: { + user: { select: { userNo: true, nickname: true, phone: true } }, + delivery: { select: { trackingNo: true, provider: true } }, + }, + }), + this.prisma.order.count({ where: { orderNo: { contains: keyword } } }), + ]); + return { + total, + items: items.map((o) => ({ + orderNo: o.orderNo, + status: o.status, + payStatus: o.payStatus, + deliveryType: o.deliveryType, + productName: o.productName, + quantity: o.quantity, + payAmount: asNumber(o.payAmount), + user: toWecomPluginUserView({ + userNo: o.user?.userNo || '—', + nickname: o.user?.nickname, + phone: o.user?.phone, + }), + receiverName: o.receiverName, + receiverPhone: maskContactPhone(o.receiverPhone), + receiverCity: o.receiverCity, + trackingNo: o.delivery?.trackingNo || null, + createdAt: o.createdAt.toISOString(), + })), + }; + }, + ); + } + + queryUsers(wecomUserId: string, q?: string, page?: string, pageSize?: string) { + const keyword = requireQuery(q); + const take = clampWecomPluginPageSize(pageSize); + const skip = (clampWecomPluginPage(page) - 1) * take; + return this.audit.run( + { + bot: WECOM_PLUGIN_AUDIT_BOT, + wecomUserId, + action: 'plugin.user.read', + permission: 'user.read', + inputSummary: MOBILE_PHONE_RE.test(keyword) ? maskContactPhone(keyword) : keyword, + }, + async () => { + const where = MOBILE_PHONE_RE.test(keyword) + ? { phone: keyword, mergedIntoUserId: null } + : { userNo: { contains: keyword }, mergedIntoUserId: null }; + const [rows, total] = await Promise.all([ + this.prisma.user.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take, + select: { + id: true, + userNo: true, + nickname: true, + phone: true, + status: true, + createdAt: true, + _count: { select: { orders: true } }, + }, + }), + this.prisma.user.count({ where }), + ]); + const balances = await Promise.all( + rows.map((u) => + this.prisma.benefitCoupon.aggregate({ + where: { userId: u.id, status: 'ACTIVE' }, + _sum: { balance: true }, + }), + ), + ); + return { + total, + items: rows.map((u, i) => ({ + ...toWecomPluginUserView(u), + status: u.status, + orderCount: u._count.orders, + benefitBalance: asNumber(balances[i]?._sum.balance), + createdAt: u.createdAt.toISOString(), + })), + }; + }, + ); + } + + queryStores(wecomUserId: string, q?: string, page?: string, pageSize?: string) { + const keyword = requireQuery(q); + const take = clampWecomPluginPageSize(pageSize); + const skip = (clampWecomPluginPage(page) - 1) * take; + return this.audit.run( + { + bot: WECOM_PLUGIN_AUDIT_BOT, + wecomUserId, + action: 'plugin.store.read', + permission: 'store.read', + inputSummary: keyword, + }, + async () => { + const where = { name: { contains: keyword } }; + const [rows, total] = await Promise.all([ + this.prisma.store.findMany({ + where, + orderBy: { updatedAt: 'desc' }, + skip, + take, + select: { + name: true, + status: true, + cityName: true, + district: true, + address: true, + contactPhone: true, + phone: true, + }, + }), + this.prisma.store.count({ where }), + ]); + return { + total, + items: rows.map((s) => ({ + name: s.name, + status: s.status, + cityName: s.cityName, + district: s.district, + address: s.address, + contactPhone: maskContactPhone(s.contactPhone || s.phone), + })), + }; + }, + ); + } + + queryRedeems(wecomUserId: string, q?: string, page?: string, pageSize?: string) { + const keyword = requireQuery(q); + const take = clampWecomPluginPageSize(pageSize); + const skip = (clampWecomPluginPage(page) - 1) * take; + return this.audit.run( + { + bot: WECOM_PLUGIN_AUDIT_BOT, + wecomUserId, + action: 'plugin.redeem.read', + permission: 'redeem.read', + inputSummary: keyword, + }, + async () => { + const byNo = await this.prisma.redeemRecord.findMany({ + where: { redeemNo: { contains: keyword } }, + orderBy: { createdAt: 'desc' }, + skip, + take, + include: { store: { select: { name: true } } }, + }); + const rows = + byNo.length > 0 + ? byNo + : await this.prisma.redeemRecord.findMany({ + where: { store: { name: { contains: keyword } } }, + orderBy: { createdAt: 'desc' }, + skip, + take, + include: { store: { select: { name: true } } }, + }); + const total = + byNo.length > 0 + ? await this.prisma.redeemRecord.count({ where: { redeemNo: { contains: keyword } } }) + : await this.prisma.redeemRecord.count({ + where: { store: { name: { contains: keyword } } }, + }); + return { + total, + items: rows.map((r) => ({ + redeemNo: r.redeemNo, + amount: asNumber(r.amount), + channel: r.channel, + storeName: r.store?.name || '—', + createdAt: r.createdAt.toISOString(), + })), + }; + }, + ); + } + + queryPromoCodes(wecomUserId: string, q?: string, page?: string, pageSize?: string) { + const keyword = requireQuery(q); + const take = clampWecomPluginPageSize(pageSize); + const skip = (clampWecomPluginPage(page) - 1) * take; + return this.audit.run( + { + bot: WECOM_PLUGIN_AUDIT_BOT, + wecomUserId, + action: 'plugin.promo.read', + inputSummary: keyword, + }, + async () => { + const where = { + OR: [ + { code: { contains: keyword.toUpperCase() } }, + { name: { contains: keyword } }, + ], + }; + const [rows, total] = await Promise.all([ + this.prisma.commonPromoCode.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take, + select: { + code: true, + name: true, + scene: true, + status: true, + scanCount: true, + orderCount: true, + }, + }), + this.prisma.commonPromoCode.count({ where }), + ]); + return { total, items: rows }; + }, + ); + } + + queryPromoCodeStats(wecomUserId: string, code?: string) { + const keyword = requireQuery(code); + return this.audit.run( + { + bot: WECOM_PLUGIN_AUDIT_BOT, + wecomUserId, + action: 'plugin.promo.stats', + inputSummary: keyword, + }, + async () => { + const row = + (await this.prisma.commonPromoCode.findUnique({ + where: { code: keyword.toUpperCase() }, + select: { id: true, code: true, name: true, status: true }, + })) || + (await this.prisma.commonPromoCode.findFirst({ + where: { code: { contains: keyword.toUpperCase() } }, + select: { id: true, code: true, name: true, status: true }, + })); + if (!row) throw new NotFoundException(`未找到推广码:${keyword}`); + const stats = await this.promo.stats(row.id); + return { code: row.code, name: row.name, status: row.status, stats }; + }, + ); + } + + queryMetrics(wecomUserId: string, kindRaw?: string) { + const kind = parseWecomPluginMetricsKind(kindRaw); + if (!kind) { + throw new BadRequestException('kind 须为 today | daily | weekly | monthly'); + } + return this.audit.run( + { + bot: WECOM_PLUGIN_AUDIT_BOT, + wecomUserId, + action: 'plugin.metrics.read', + inputSummary: kind, + }, + async () => { + const period = wecomPluginMetricsPeriod(kind); + const stats = await this.loadStats(period.start, period.endExclusive); + return { + kind: period.kind, + title: period.title, + rangeLabel: period.rangeLabel, + incrementLabel: period.incrementLabel, + periodKey: period.periodKey, + stats, + }; + }, + ); + } + + /** 日报口径:用户=有效未合并;订单金额=已付 payAmount;核销=RedeemRecord。today 期末为当前时刻。 */ + private async loadStats(start: Date, cutoff: Date): Promise { + const userBase = { status: 1, mergedIntoUserId: null } as const; + const partnerBase = { isPrimary: 1 } as const; + const paid = { payStatus: 'PAID' as const }; + + const [ + usersTotal, + usersIncrement, + partnersTotal, + partnersIncrement, + storesTotal, + storesIncrement, + ordersTotal, + ordersIncrement, + orderAmountTotal, + orderAmountIncrement, + redeemsTotal, + redeemsIncrement, + redeemAmountTotal, + redeemAmountIncrement, + ] = await Promise.all([ + this.prisma.user.count({ where: { ...userBase, createdAt: { lt: cutoff } } }), + this.prisma.user.count({ + where: { ...userBase, createdAt: { gte: start, lt: cutoff } }, + }), + this.prisma.partnerAccount.count({ + where: { ...partnerBase, createdAt: { lt: cutoff } }, + }), + this.prisma.partnerAccount.count({ + where: { ...partnerBase, createdAt: { gte: start, lt: cutoff } }, + }), + this.prisma.store.count({ where: { createdAt: { lt: cutoff } } }), + this.prisma.store.count({ where: { createdAt: { gte: start, lt: cutoff } } }), + this.prisma.order.count({ where: { createdAt: { lt: cutoff } } }), + this.prisma.order.count({ where: { createdAt: { gte: start, lt: cutoff } } }), + this.prisma.order.aggregate({ + _sum: { payAmount: true }, + where: { ...paid, paidAt: { lt: cutoff } }, + }), + this.prisma.order.aggregate({ + _sum: { payAmount: true }, + where: { ...paid, paidAt: { gte: start, lt: cutoff } }, + }), + this.prisma.redeemRecord.count({ where: { createdAt: { lt: cutoff } } }), + this.prisma.redeemRecord.count({ where: { createdAt: { gte: start, lt: cutoff } } }), + this.prisma.redeemRecord.aggregate({ + _sum: { amount: true }, + where: { createdAt: { lt: cutoff } }, + }), + this.prisma.redeemRecord.aggregate({ + _sum: { amount: true }, + where: { createdAt: { gte: start, lt: cutoff } }, + }), + ]); + + return { + usersTotal, + usersIncrement, + partnersTotal, + partnersIncrement, + storesTotal, + storesIncrement, + ordersTotal, + ordersIncrement, + orderAmountTotal: asNumber(orderAmountTotal._sum.payAmount), + orderAmountIncrement: asNumber(orderAmountIncrement._sum.payAmount), + redeemsTotal, + redeemsIncrement, + redeemAmountTotal: asNumber(redeemAmountTotal._sum.amount), + redeemAmountIncrement: asNumber(redeemAmountIncrement._sum.amount), + }; + } +} diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin.constants.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin.constants.ts new file mode 100644 index 0000000..acf022d --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin.constants.ts @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..2ab59b2 --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin.controller.ts @@ -0,0 +1,87 @@ +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'; + +@Controller('wecom/plugin') +@UseGuards(WecomPluginGuard) +export class WecomPluginController { + constructor(private readonly query: WecomPluginQueryService) {} + + @Get() + info() { + return this.query.info(); + } + + @Get('openapi.json') + openapi() { + return WECOM_PLUGIN_OPENAPI; + } + + @Get('orders') + orders( + @Req() req: Request, + @Query('q') q?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.query.queryOrders(pluginCaller(req), q, page, pageSize); + } + + @Get('users') + users( + @Req() req: Request, + @Query('q') q?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.query.queryUsers(pluginCaller(req), q, page, pageSize); + } + + @Get('stores') + stores( + @Req() req: Request, + @Query('q') q?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.query.queryStores(pluginCaller(req), q, page, pageSize); + } + + @Get('redeems') + redeems( + @Req() req: Request, + @Query('q') q?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.query.queryRedeems(pluginCaller(req), q, page, pageSize); + } + + @Get('promo-codes') + promoCodes( + @Req() req: Request, + @Query('q') q?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.query.queryPromoCodes(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); + } + + @Get('metrics') + metrics(@Req() req: Request, @Query('kind') kind?: string) { + return this.query.queryMetrics(pluginCaller(req), kind); + } +} + +function pluginCaller(req: Request): string { + const raw = req.headers['x-wecom-userid'] ?? req.headers['userid']; + const v = Array.isArray(raw) ? raw[0] : raw; + return String(v || 'plugin').trim() || 'plugin'; +} diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin.guard.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin.guard.ts new file mode 100644 index 0000000..92505d0 --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin.guard.ts @@ -0,0 +1,28 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { isWecomPluginEnabled, verifyWecomPluginApiKey } from '@dukang/domain'; + +@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 }>(); + const provided = headerValue(req.headers, 'x-api-key'); + if (!verifyWecomPluginApiKey(provided, process.env.WECOM_PLUGIN_API_KEY)) { + throw new UnauthorizedException('Unauthorized'); + } + return true; + } +} + +function headerValue(headers: Record, name: string): string { + const raw = headers[name]; + if (Array.isArray(raw)) return String(raw[0] ?? '').trim(); + return String(raw ?? '').trim(); +} diff --git a/server/dukang-api/src/integrations/wecom/wecom-plugin.openapi.ts b/server/dukang-api/src/integrations/wecom/wecom-plugin.openapi.ts new file mode 100644 index 0000000..8b73957 --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-plugin.openapi.ts @@ -0,0 +1,157 @@ +const envelope = (dataSchema: Record) => ({ + type: 'object', + properties: { + code: { type: 'integer', example: 0 }, + message: { type: 'string', example: 'ok' }, + data: dataSchema, + }, + required: ['code', 'message', 'data'], +}); + +const qParam = { + name: 'q', + in: 'query', + required: true, + schema: { type: 'string' }, + description: '查询关键词', +}; + +const pageParams = [ + { + name: 'page', + in: 'query', + required: false, + schema: { type: 'integer', default: 1, minimum: 1 }, + }, + { + name: 'pageSize', + in: 'query', + required: false, + schema: { type: 'integer', default: 5, minimum: 1, maximum: 10 }, + description: '默认 5,最大 10', + }, +]; + +const unauthorized = { + description: '缺少或错误的 X-Api-Key,或插件未启用', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + code: { type: 'integer' }, + message: { type: 'string' }, + }, + }, + }, + }, +}; + +function listPath(summary: string, description: string, qDescription: string) { + return { + get: { + summary, + description, + operationId: summary, + parameters: [{ ...qParam, description: qDescription }, ...pageParams], + responses: { + 200: { + description: '查询结果', + content: { + 'application/json': { + schema: envelope({ + type: 'object', + properties: { + total: { type: 'integer' }, + items: { type: 'array', items: { type: 'object' } }, + }, + }), + }, + }, + }, + 401: unauthorized, + }, + }, + }; +} + +/** OpenAPI 3.0:企微「添加插件工具」可导入。须原样返回,不要套 {code,message,data}。 */ +export const WECOM_PLUGIN_OPENAPI = { + openapi: '3.0.3', + info: { + title: '杜康好客运营查询', + description: + '企业内部只读查询。手机号已脱敏。鉴权:Header X-Api-Key。响应除本文件外均为 { code, message, data }。', + version: '1.0.0', + }, + servers: [ + { url: 'https://api.dukanghaoke.com/api/v1/wecom/plugin', description: '生产' }, + { url: 'https://api-test.dukanghaoke.com/api/v1/wecom/plugin', description: '测试' }, + ], + security: [{ ApiKeyAuth: [] }], + components: { + securitySchemes: { + ApiKeyAuth: { + type: 'apiKey', + in: 'header', + name: 'X-Api-Key', + }, + }, + }, + paths: { + '/orders': listPath('查询订单', '按订单号模糊查询', '订单号,如 DK20260903xxxx'), + '/users': listPath('查询用户', '按用户号或 11 位手机号查询;手机号脱敏', '用户号或手机号'), + '/stores': listPath('查询门店', '按门店名称模糊查询', '门店名称关键词'), + '/redeems': listPath('查询核销', '按核销单号或门店名查询', '核销单号或门店名'), + '/promo-codes': listPath('查询推广码', '按推广码 code 或名称查询', '推广码或名称'), + '/promo-codes/{code}/stats': { + get: { + summary: '推广码统计', + operationId: '查询推广码统计', + parameters: [ + { + name: 'code', + in: 'path', + required: true, + schema: { type: 'string' }, + description: '推广码 code', + }, + ], + responses: { + 200: { + description: '扫码/成交统计', + content: { 'application/json': { schema: envelope({ type: 'object' }) } }, + }, + 401: unauthorized, + }, + }, + }, + '/metrics': { + get: { + summary: '经营指标', + description: + 'today=今日截至当前;daily/weekly/monthly 与企微经营报告同一口径(用户有效未合并,订单金额=已付 payAmount)。', + operationId: '查询经营指标', + parameters: [ + { + name: 'kind', + in: 'query', + required: false, + schema: { + type: 'string', + enum: ['today', 'daily', 'weekly', 'monthly'], + default: 'today', + }, + }, + ], + responses: { + 200: { + description: '存量与新增', + content: { 'application/json': { schema: envelope({ type: 'object' }) } }, + }, + 401: unauthorized, + }, + }, + }, + }, +} as const; diff --git a/server/dukang-api/src/integrations/wecom/wecom.module.ts b/server/dukang-api/src/integrations/wecom/wecom.module.ts index 433cb92..f10ee54 100644 --- a/server/dukang-api/src/integrations/wecom/wecom.module.ts +++ b/server/dukang-api/src/integrations/wecom/wecom.module.ts @@ -1,33 +1,41 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { CommonModule } from '../../modules/common/common.module'; -import { DevPlanModule } from '../../modules/dev-plan/dev-plan.module'; -import { SettlementModule } from '../../modules/settlement/settlement.module'; -import { IntegrationsModule } from '../integrations.module'; -import { LlmModule } from '../llm/llm.module'; -import { WecomAibotService } from './wecom-aibot.service'; -import { WecomBotActionsService } from './wecom-bot-actions.service'; -import { WecomBotAiService } from './wecom-bot-ai.service'; -import { WecomBotAuditService } from './wecom-bot-audit.service'; -import { WecomBotCapabilityService } from './wecom-bot-capability.service'; -import { WecomBotSessionService } from './wecom-bot-session.service'; - -/** 企微多机器人:依赖 Common(工单)+ Settlement/DevPlan + Integrations(短信)+ Llm */ -@Module({ - imports: [ - forwardRef(() => CommonModule), - forwardRef(() => IntegrationsModule), - DevPlanModule, - SettlementModule, - LlmModule, - ], - providers: [ - WecomBotSessionService, - WecomBotAuditService, - WecomBotCapabilityService, - WecomBotActionsService, - WecomBotAiService, - WecomAibotService, - ], - exports: [WecomAibotService, WecomBotAuditService], -}) -export class WecomModule {} +import { Module, forwardRef } from '@nestjs/common'; +import { CommonModule } from '../../modules/common/common.module'; +import { DevPlanModule } from '../../modules/dev-plan/dev-plan.module'; +import { PromoModule } from '../../modules/promo/promo.module'; +import { SettlementModule } from '../../modules/settlement/settlement.module'; +import { IntegrationsModule } from '../integrations.module'; +import { LlmModule } from '../llm/llm.module'; +import { WecomAibotService } from './wecom-aibot.service'; +import { WecomBotActionsService } from './wecom-bot-actions.service'; +import { WecomBotAiService } from './wecom-bot-ai.service'; +import { WecomBotAuditService } from './wecom-bot-audit.service'; +import { WecomBotCapabilityService } from './wecom-bot-capability.service'; +import { WecomBotSessionService } from './wecom-bot-session.service'; +import { WecomPluginController } from './wecom-plugin.controller'; +import { WecomPluginGuard } from './wecom-plugin.guard'; +import { WecomPluginQueryService } from './wecom-plugin-query.service'; + +/** 企微多机器人:依赖 Common(工单)+ Settlement/DevPlan + Promo + Integrations(短信)+ Llm */ +@Module({ + imports: [ + forwardRef(() => CommonModule), + forwardRef(() => IntegrationsModule), + DevPlanModule, + SettlementModule, + PromoModule, + LlmModule, + ], + controllers: [WecomPluginController], + providers: [ + WecomBotSessionService, + WecomBotAuditService, + WecomBotCapabilityService, + WecomBotActionsService, + WecomBotAiService, + WecomAibotService, + WecomPluginGuard, + WecomPluginQueryService, + ], + exports: [WecomAibotService, WecomBotAuditService], +}) +export class WecomModule {}