v4.0.17企业微信API插件优化

This commit is contained in:
2026-09-06 09:36:49 +08:00
parent 0e711be6c6
commit 192a401227
36 changed files with 2416 additions and 103 deletions
+43
View File
@@ -3,7 +3,10 @@ import { maskContactPhone } from './phone';
import {
clampWecomPluginPageSize,
isWecomPluginEnabled,
matchWecomPluginByApiKey,
parseWecomPluginDateRange,
parseWecomPluginMetricsKind,
toWecomPluginMetricsView,
toWecomPluginUserView,
verifyWecomPluginApiKey,
wecomPluginMetricsPeriod,
@@ -23,6 +26,17 @@ describe('verifyWecomPluginApiKey', () => {
});
});
describe('matchWecomPluginByApiKey', () => {
it('returns the matching enabled instance', () => {
const rows = [
{ id: '1', apiKey: 'alpha-key-aaaa' },
{ id: '2', apiKey: 'beta-key-bbbbb' },
];
expect(matchWecomPluginByApiKey('beta-key-bbbbb', rows)?.id).toBe('2');
expect(matchWecomPluginByApiKey('missing', rows)).toBeNull();
});
});
describe('isWecomPluginEnabled', () => {
it('requires both the switch and a non-empty key', () => {
expect(isWecomPluginEnabled({ WECOM_PLUGIN_ENABLED: 'true', WECOM_PLUGIN_API_KEY: 'k' })).toBe(
@@ -52,6 +66,35 @@ describe('clampWecomPluginPageSize / parseWecomPluginMetricsKind', () => {
});
});
describe('parseWecomPluginDateRange / toWecomPluginMetricsView', () => {
it('parses YYYY-MM-DD range with inclusive end day', () => {
const range = parseWecomPluginDateRange('2026-09-01', '2026-09-03');
expect(range?.gte?.toISOString()).toBe(new Date('2026-09-01T00:00:00+08:00').toISOString());
expect(range?.lt?.toISOString()).toBe(new Date('2026-09-04T00:00:00+08:00').toISOString());
});
it('exposes newStores alias on metrics stats', () => {
const view = toWecomPluginMetricsView({
usersTotal: 1,
usersIncrement: 0,
partnersTotal: 1,
partnersIncrement: 0,
storesTotal: 10,
storesIncrement: 2,
ordersTotal: 0,
ordersIncrement: 0,
orderAmountTotal: 0,
orderAmountIncrement: 0,
redeemsTotal: 0,
redeemsIncrement: 0,
redeemAmountTotal: 0,
redeemAmountIncrement: 0,
});
expect(view.newStores).toBe(2);
expect(view.storesIncrement).toBe(2);
});
});
describe('toWecomPluginUserView', () => {
it('masks phone and fills empty nickname', () => {
expect(toWecomPluginUserView({ userNo: 'DK1', nickname: null, phone: '13800138000' })).toEqual({
+60 -2
View File
@@ -1,6 +1,11 @@
import { maskContactPhone } from './phone';
import { shanghaiYmd, startOfShanghaiDay } from './shanghai-date';
import { wecomReportPeriod, type WecomReportKind, type WecomReportPeriod } from './wecom-report';
import { addShanghaiDays, shanghaiYmd, startOfShanghaiDay } from './shanghai-date';
import {
wecomReportPeriod,
type WecomReportKind,
type WecomReportPeriod,
type WecomReportStats,
} from './wecom-report';
export const WECOM_PLUGIN_PAGE_SIZE_DEFAULT = 5;
export const WECOM_PLUGIN_PAGE_SIZE_MAX = 10;
@@ -53,6 +58,20 @@ export function isWecomPluginEnabled(env: {
return env.WECOM_PLUGIN_ENABLED === 'true' && Boolean(String(env.WECOM_PLUGIN_API_KEY ?? '').trim());
}
/** 在少量启用实例中做常量时间匹配(长度不等仍会先失败) */
export function matchWecomPluginByApiKey<T extends { apiKey: string }>(
provided: string,
candidates: T[],
): T | null {
const got = String(provided ?? '').trim();
if (!got) return null;
let hit: T | null = null;
for (const row of candidates) {
if (verifyWecomPluginApiKey(got, row.apiKey)) hit = row;
}
return hit;
}
export function wecomPluginMetricsPeriod(
kind: WecomPluginMetricsKind,
now = new Date(),
@@ -74,6 +93,45 @@ export function wecomPluginMetricsPeriod(
return { ...period, kind };
}
export type WecomPluginDateRange = { gte?: Date; lt?: Date };
/** 插件时间筛选:from/to 支持 YYYY-MM-DD 或 ISO;日期含当天全天 */
export function parseWecomPluginDateRange(
fromRaw?: string | null,
toRaw?: string | null,
): WecomPluginDateRange | undefined {
const from = parseWecomPluginDate(fromRaw);
const to = parseWecomPluginDate(toRaw);
if (!from && !to) return undefined;
const range: WecomPluginDateRange = {};
if (from) range.gte = from;
if (to) {
range.lt = isWecomPluginDateOnly(toRaw) ? addShanghaiDays(startOfShanghaiDay(to), 1) : to;
}
return range;
}
function isWecomPluginDateOnly(raw?: string | null): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(String(raw ?? '').trim());
}
function parseWecomPluginDate(raw?: string | null): Date | null {
const v = String(raw ?? '').trim();
if (!v) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) {
return startOfShanghaiDay(new Date(`${v}T00:00:00+08:00`));
}
const d = new Date(v);
return Number.isFinite(d.getTime()) ? d : null;
}
export function toWecomPluginMetricsView(stats: WecomReportStats) {
return {
...stats,
newStores: stats.storesIncrement,
};
}
export function toWecomPluginUserView(user: {
userNo: string;
nickname?: string | null;
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest';
import {
WECOM_PLUGIN_API_KEY_HEADER,
WECOM_PLUGIN_BASE_PATH,
WECOM_PLUGIN_PATH_PERMISSION,
allowedWecomPluginOpenApiPaths,
maskWecomPluginApiKey,
parseWecomPluginPermissions,
resolveWecomPluginPublicUrl,
wecomPluginHasPermission,
} from './wecom-plugin';
describe('resolveWecomPluginPublicUrl', () => {
@@ -31,3 +36,44 @@ describe('resolveWecomPluginPublicUrl', () => {
expect(WECOM_PLUGIN_API_KEY_HEADER).toBe('X-Api-Key');
});
});
describe('parseWecomPluginPermissions', () => {
it('keeps catalog keys and drops unknown', () => {
expect(parseWecomPluginPermissions(['order.read', 'bogus', 'metrics.read'])).toEqual([
'order.read',
'metrics.read',
]);
expect(parseWecomPluginPermissions('["user.read","store.read"]')).toEqual([
'user.read',
'store.read',
]);
});
});
describe('maskWecomPluginApiKey / path permission map', () => {
it('masks middle of the key', () => {
expect(maskWecomPluginApiKey('WAv2xxxxYb9')).toBe('WAv2••••xYb9');
});
it('maps openapi paths to tool permissions', () => {
expect(WECOM_PLUGIN_PATH_PERMISSION['/orders']).toBe('order.read');
expect(WECOM_PLUGIN_PATH_PERMISSION['/promo-codes/{code}/stats']).toBe('promo.read');
});
it('filters openapi paths by instance permissions', () => {
expect(allowedWecomPluginOpenApiPaths(['order.read', 'user.read'])).toEqual([
'/orders',
'/users',
]);
expect(allowedWecomPluginOpenApiPaths(['store.audit.read'])).toEqual([
'/store-audits',
'/store-info-audits',
'/store-info-audits/{id}',
'/store-package-audits',
'/store-package-audits/{id}',
]);
expect(allowedWecomPluginOpenApiPaths(['partner.read'])).toContain('/partners');
expect(wecomPluginHasPermission(['order.read'], 'order.read')).toBe(true);
expect(wecomPluginHasPermission(['order.read'], 'metrics.read')).toBe(false);
});
});
+182
View File
@@ -12,6 +12,188 @@ export const WECOM_PLUGIN_METRICS_KINDS = ['today', 'daily', 'weekly', 'monthly'
export type WecomPluginMetricsKind = (typeof WECOM_PLUGIN_METRICS_KINDS)[number];
export const WECOM_PLUGIN_PERMISSIONS = [
'order.read',
'user.read',
'store.read',
'redeem.read',
'promo.read',
'metrics.read',
'store.audit.read',
'partner.read',
] as const;
export type WecomPluginPermission = (typeof WECOM_PLUGIN_PERMISSIONS)[number];
export const WECOM_PLUGIN_PERMISSION_LABELS: Record<WecomPluginPermission, string> = {
'order.read': '查询订单',
'user.read': '查询用户',
'store.read': '查询门店',
'redeem.read': '查询核销',
'promo.read': '查询推广码',
'metrics.read': '经营指标',
'store.audit.read': '门店/套餐审核',
'partner.read': '合伙人关联查询',
};
export const WECOM_PLUGIN_PERMISSION_GROUPS: Array<{
key: string;
label: string;
permissions: WecomPluginPermission[];
}> = [
{
key: 'query',
label: '只读查询',
permissions: [
'order.read',
'user.read',
'store.read',
'redeem.read',
'promo.read',
'metrics.read',
'partner.read',
],
},
{
key: 'audit',
label: '审核对照',
permissions: ['store.audit.read'],
},
];
/** OpenAPI path → 工具权限 */
export const WECOM_PLUGIN_PATH_PERMISSION: Record<string, WecomPluginPermission> = {
'/orders': 'order.read',
'/users': 'user.read',
'/stores': 'store.read',
'/redeems': 'redeem.read',
'/promo-codes': 'promo.read',
'/promo-codes/{code}/stats': 'promo.read',
'/metrics': 'metrics.read',
'/store-audits': 'store.audit.read',
'/store-info-audits': 'store.audit.read',
'/store-info-audits/{id}': 'store.audit.read',
'/store-package-audits': 'store.audit.read',
'/store-package-audits/{id}': 'store.audit.read',
'/partners': 'partner.read',
'/partners/{partnerId}/users': 'partner.read',
'/partners/{partnerId}/stores': 'partner.read',
'/partners/{partnerId}/orders': 'partner.read',
};
export const WECOM_PLUGIN_TOOL_PATHS: Record<WecomPluginPermission, string[]> = {
'order.read': ['/orders'],
'user.read': ['/users'],
'store.read': ['/stores'],
'redeem.read': ['/redeems'],
'promo.read': ['/promo-codes', '/promo-codes/{code}/stats'],
'metrics.read': ['/metrics'],
'store.audit.read': [
'/store-audits',
'/store-info-audits',
'/store-info-audits/{id}',
'/store-package-audits',
'/store-package-audits/{id}',
],
'partner.read': [
'/partners',
'/partners/{partnerId}/users',
'/partners/{partnerId}/stores',
'/partners/{partnerId}/orders',
],
};
export function parseWecomPluginPermissions(
raw?: string | string[] | null,
): WecomPluginPermission[] {
const valid = new Set<string>(WECOM_PLUGIN_PERMISSIONS);
let list: string[];
if (Array.isArray(raw)) {
list = raw.map(String);
} else {
const text = String(raw ?? '').trim();
if (!text) {
list = [];
} else if (text.startsWith('[')) {
try {
const parsed: unknown = JSON.parse(text);
list = Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
list = text.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
}
} else {
list = text.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
}
}
return [...new Set(list.filter((s): s is WecomPluginPermission => valid.has(s)))];
}
export function wecomPluginHasPermission(
permissions: WecomPluginPermission[],
permission: WecomPluginPermission,
): boolean {
return permissions.includes(permission);
}
/** 当前实例可暴露给企微第 2 步的 OpenAPI paths */
export function allowedWecomPluginOpenApiPaths(
permissions: WecomPluginPermission[],
): string[] {
const allowed = new Set(permissions);
return Object.entries(WECOM_PLUGIN_PATH_PERMISSION)
.filter(([, perm]) => allowed.has(perm))
.map(([path]) => path);
}
export function maskWecomPluginApiKey(apiKey?: string | null): string {
const v = String(apiKey ?? '').trim();
if (!v) return '未配置';
if (v.length <= 8) return '••••';
return `${v.slice(0, 4)}••••${v.slice(-4)}`;
}
export type WecomApiPluginDto = {
id: string;
name: string;
apiKeyConfigured: boolean;
apiKeyMasked: string;
permissions: WecomPluginPermission[];
remark: string | null;
enabled: boolean;
sortOrder: number;
createdAt: string;
updatedAt: string;
};
export type CreateWecomApiPluginRequest = {
name: string;
apiKey?: string;
permissions: WecomPluginPermission[];
remark?: string | null;
enabled?: boolean;
sortOrder?: number;
};
export type UpdateWecomApiPluginRequest = {
name?: string;
apiKey?: string;
permissions?: WecomPluginPermission[];
remark?: string | null;
enabled?: boolean;
sortOrder?: number;
};
export type WecomApiPluginSecretDto = WecomApiPluginDto & {
apiKey: string;
};
export type WecomApiPluginListDto = {
items: WecomApiPluginDto[];
total: number;
page: number;
pageSize: number;
};
/** 按 HQ 当前域名推断插件公网 Base URL(不含密钥) */
export function resolveWecomPluginPublicUrl(hostname: string): string {
const host = String(hostname || '').toLowerCase();