企业微信智能机器人API插件

This commit is contained in:
2026-09-03 21:29:10 +08:00
parent ec9b7efcd6
commit 418e13a5e3
22 changed files with 1154 additions and 33 deletions
+1
View File
@@ -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';
+75
View File
@@ -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());
});
});
+87
View File
@@ -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),
};
}
+1
View File
@@ -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');
});
});
+25
View File
@@ -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}`;
}