v4.0.17企业微信API插件优化
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user