企业微信智能机器人API插件
This commit is contained in:
@@ -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() {
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="企微 API 插件(与上方长连接机器人独立)"
|
||||
description={
|
||||
<div>
|
||||
<div>
|
||||
插件 URL:
|
||||
<Typography.Text copyable>
|
||||
{resolveWecomPluginPublicUrl(window.location.hostname)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
OpenAPI:
|
||||
<Typography.Text copyable>
|
||||
{`${resolveWecomPluginPublicUrl(window.location.hostname)}/openapi.json`}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text type="secondary">
|
||||
授权方式 Service token / API key,Header 名 {WECOM_PLUGIN_API_KEY_HEADER}
|
||||
;密钥只在服务器 .env 的 WECOM_PLUGIN_API_KEY,本页不展示。须先开
|
||||
WECOM_PLUGIN_ENABLED=true。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
|
||||
@@ -68,9 +68,11 @@
|
||||
| 3.5.11 | [`城市履约起购 + 企微通知字段与结算通知`](./杜康好客-v3.5.11-开发文档.md) | 🔶 开发完成 |
|
||||
| 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) | 🔶 开发完成 |
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 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` |
|
||||
| 2026-08-26 | v3.5.12:订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线英文码改中文;修复删除门店分类后被 `ensureDefaults` 回种;HQ 侧栏按业务前 11 项重排、系统设置置底 |
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# 杜康好客 · v3.5.16 开发文档
|
||||
|
||||
> **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='<WECOM_PLUGIN_API_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 行为不变
|
||||
@@ -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 回退小程序原生客服。
|
||||
|
||||
@@ -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`;小程序须已关联该企业微信客服 |
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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<WecomReportPeriod, 'kind'> & {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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<unknown> {
|
||||
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 {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WecomReportStats> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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<string, unknown> }>();
|
||||
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<string, unknown>, name: string): string {
|
||||
const raw = headers[name];
|
||||
if (Array.isArray(raw)) return String(raw[0] ?? '').trim();
|
||||
return String(raw ?? '').trim();
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
const envelope = (dataSchema: Record<string, unknown>) => ({
|
||||
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;
|
||||
@@ -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),
|
||||
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 {}
|
||||
|
||||
Reference in New Issue
Block a user