feat(ops): 总部可按百分比限制接口放行并开关企微通知

线上需要按账户、用户和功能控制登录与加载成功率,同时单独停发订单、核销和账单通知。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-26 12:04:16 +08:00
parent 674b6ac31b
commit cb63d382ad
29 changed files with 1888 additions and 18 deletions
+182
View File
@@ -0,0 +1,182 @@
import { describe, expect, it } from 'vitest';
import {
allowBySuccessPercent,
classifyApiAccessRoute,
decideWecomSend,
resolveApiAccessPercent,
type ApiAccessPercentRule,
} from './api-access';
function rule(partial: Partial<ApiAccessPercentRule> & Pick<ApiAccessPercentRule, 'scopeType' | 'featureKey'>): ApiAccessPercentRule {
return {
actorType: '',
actorId: '0',
successPercent: 100,
errorKind: 'request_error',
...partial,
};
}
describe('resolveApiAccessPercent', () => {
const rules: ApiAccessPercentRule[] = [
rule({ scopeType: 'global', featureKey: 'login', successPercent: 90, errorKind: 'request_error' }),
rule({
scopeType: 'account',
featureKey: '',
actorType: 'PARTNER',
actorId: '7',
successPercent: 40,
errorKind: 'network_load_failed',
}),
rule({
scopeType: 'account',
featureKey: 'login',
actorType: 'PARTNER',
actorId: '7',
successPercent: 10,
errorKind: 'illegal_access',
}),
rule({
scopeType: 'user',
featureKey: '',
actorType: 'USER',
actorId: '3',
successPercent: 70,
}),
rule({
scopeType: 'user',
featureKey: 'product_load',
actorType: 'USER',
actorId: '3',
successPercent: 5,
errorKind: 'wechat_service_error',
}),
];
it('uses the most specific matching rule', () => {
expect(resolveApiAccessPercent(rules, 'login', { accountType: 'PARTNER', accountId: '7' })).toEqual({
successPercent: 10,
errorKind: 'illegal_access',
});
expect(resolveApiAccessPercent(rules, 'store_submit', { accountType: 'PARTNER', accountId: '7' })).toEqual({
successPercent: 40,
errorKind: 'network_load_failed',
});
expect(resolveApiAccessPercent(rules, 'product_load', { userId: '3' })).toEqual({
successPercent: 5,
errorKind: 'wechat_service_error',
});
expect(resolveApiAccessPercent(rules, 'store_load', { userId: '3' })).toEqual({
successPercent: 70,
errorKind: 'request_error',
});
expect(resolveApiAccessPercent(rules, 'login', {})).toEqual({
successPercent: 90,
errorKind: 'request_error',
});
});
it('prefers a user rule over an account rule on the same feature', () => {
const mixed = [
...rules,
rule({
scopeType: 'account',
featureKey: 'product_load',
actorType: 'HQ',
actorId: '1',
successPercent: 1,
}),
];
expect(
resolveApiAccessPercent(mixed, 'product_load', {
userId: '3',
accountType: 'HQ',
accountId: '1',
}).successPercent,
).toBe(5);
});
it('allows when nothing matches', () => {
expect(resolveApiAccessPercent([], 'login', {})).toEqual({
successPercent: 100,
errorKind: 'request_error',
});
});
});
describe('allowBySuccessPercent', () => {
it('always allows 100 and always denies 0', () => {
expect(allowBySuccessPercent(100, 99.9)).toBe(true);
expect(allowBySuccessPercent(0, 0)).toBe(false);
expect(allowBySuccessPercent(150, 99)).toBe(true);
expect(allowBySuccessPercent(-1, 0)).toBe(false);
});
it('compares the roll against the percent', () => {
expect(allowBySuccessPercent(50, 49.9)).toBe(true);
expect(allowBySuccessPercent(50, 50)).toBe(false);
});
});
describe('classifyApiAccessRoute', () => {
it('matches client login, catalog, stores, and partner store create', () => {
expect(classifyApiAccessRoute('POST', '/api/v1/auth/login/sms')).toEqual({
feature: 'login',
loginChannel: 'user',
});
expect(classifyApiAccessRoute('POST', '/api/v1/shop/auth/login/wechat')).toEqual({
feature: 'login',
loginChannel: 'store',
});
expect(classifyApiAccessRoute('POST', '/api/v1/partner/auth/login/sms?x=1')).toEqual({
feature: 'login',
loginChannel: 'partner',
});
expect(classifyApiAccessRoute('GET', '/catalog/products')).toEqual({ feature: 'product_load' });
expect(classifyApiAccessRoute('GET', '/api/v1/catalog/products/12')).toEqual({ feature: 'product_load' });
expect(classifyApiAccessRoute('GET', '/api/v1/stores')).toEqual({ feature: 'store_load' });
expect(classifyApiAccessRoute('GET', '/api/v1/stores/9')).toEqual({ feature: 'store_load' });
expect(classifyApiAccessRoute('POST', '/api/v1/partner/stores')).toEqual({ feature: 'store_submit' });
});
it('skips headquarters login, admin reads, and nested store routes', () => {
expect(classifyApiAccessRoute('POST', '/api/v1/admin/auth/login/password')).toBeNull();
expect(classifyApiAccessRoute('PUT', '/api/v1/admin/api-access/globals')).toBeNull();
expect(classifyApiAccessRoute('GET', '/api/v1/admin/products')).toBeNull();
expect(classifyApiAccessRoute('GET', '/api/v1/catalog/cities')).toBeNull();
expect(classifyApiAccessRoute('GET', '/api/v1/stores/9/recent-redeems')).toBeNull();
expect(classifyApiAccessRoute('POST', '/api/v1/partner/stores/send-phone-sms')).toBeNull();
});
});
describe('decideWecomSend', () => {
it('stops when the notify switch is off', () => {
expect(
decideWecomSend({
notifyEnabled: false,
applyAuditPercent: true,
successPercent: 100,
roll: 0,
}),
).toBe('switch_off');
});
it('drops audit notifications below the percent', () => {
expect(
decideWecomSend({
notifyEnabled: true,
applyAuditPercent: true,
successPercent: 20,
roll: 20,
}),
).toBe('percent_drop');
expect(
decideWecomSend({
notifyEnabled: null,
applyAuditPercent: false,
successPercent: 0,
roll: 0,
}),
).toBe('allow');
});
});
+161
View File
@@ -0,0 +1,161 @@
export const API_ACCESS_PERCENT_FEATURE_KEYS = [
'login',
'product_load',
'store_load',
'store_submit',
'audit_notify',
] as const;
export type ApiAccessPercentFeatureKey = (typeof API_ACCESS_PERCENT_FEATURE_KEYS)[number];
export type ApiAccessAccountType = 'HQ' | 'STORE' | 'PARTNER';
export interface ApiAccessSubject {
userId?: string | null;
accountType?: ApiAccessAccountType | null;
accountId?: string | null;
}
export interface ApiAccessPercentRule {
featureKey: string;
scopeType: 'global' | 'account' | 'user';
actorType: string;
actorId: string;
successPercent: number;
errorKind: string;
}
export interface ResolvedApiAccessPercent {
successPercent: number;
errorKind: string;
}
const DEFAULT_PERCENT: ResolvedApiAccessPercent = {
successPercent: 100,
errorKind: 'request_error',
};
/**
* 更具体的规则覆盖更宽的规则。
* 50 用户+功能,40 账户+功能,30 用户全部功能,20 账户全部功能,10 功能全局。
*/
export function resolveApiAccessPercent(
rules: ApiAccessPercentRule[],
feature: string,
subject: ApiAccessSubject,
): ResolvedApiAccessPercent {
let bestScore = 0;
let best: ApiAccessPercentRule | null = null;
for (const rule of rules) {
const score = scoreApiAccessRule(rule, feature, subject);
if (score > bestScore) {
bestScore = score;
best = rule;
}
}
if (!best) return DEFAULT_PERCENT;
const percent = clampPercent(best.successPercent);
return {
successPercent: percent,
errorKind: best.errorKind || DEFAULT_PERCENT.errorKind,
};
}
function scoreApiAccessRule(
rule: ApiAccessPercentRule,
feature: string,
subject: ApiAccessSubject,
): number {
const featureSpecific = rule.featureKey === feature;
const featureAll = rule.featureKey === '';
if (!featureSpecific && !featureAll) return 0;
if (rule.scopeType === 'user') {
if (!subject.userId || rule.actorType !== 'USER' || rule.actorId !== subject.userId) return 0;
return featureSpecific ? 50 : 30;
}
if (rule.scopeType === 'account') {
if (!subject.accountId || !subject.accountType) return 0;
if (rule.actorType !== subject.accountType || rule.actorId !== subject.accountId) return 0;
return featureSpecific ? 40 : 20;
}
if (rule.scopeType === 'global' && featureSpecific) return 10;
return 0;
}
export function clampPercent(value: number): number {
const n = Math.floor(Number(value));
if (!Number.isFinite(n)) return 100;
if (n <= 0) return 0;
if (n >= 100) return 100;
return n;
}
/** roll 为 [0, 100)。100 恒放行,0 恒拒绝。 */
export function allowBySuccessPercent(successPercent: number, roll: number): boolean {
const percent = clampPercent(successPercent);
if (percent >= 100) return true;
if (percent <= 0) return false;
const sample = Number(roll);
if (!Number.isFinite(sample)) return true;
return sample < percent;
}
export type ApiAccessRouteHit = {
feature: 'login' | 'product_load' | 'store_load' | 'store_submit';
loginChannel?: 'user' | 'store' | 'partner';
};
/** 识别需要百分比拦截的 HTTP 路径。总部登录与配置接口返回 null。 */
export function classifyApiAccessRoute(method: string, url: string): ApiAccessRouteHit | null {
const verb = method.toUpperCase();
const path = normalizeApiPath(url);
if (path.startsWith('/admin/auth/login') || path.startsWith('/admin/api-access')) return null;
if (verb === 'POST') {
if (path === '/auth/login/sms' || path === '/auth/login/wechat' || path === '/auth/login/wechat-phone') {
return { feature: 'login', loginChannel: 'user' };
}
if (path === '/shop/auth/login/sms' || path === '/shop/auth/login/wechat') {
return { feature: 'login', loginChannel: 'store' };
}
if (path === '/partner/auth/login/sms' || path === '/partner/auth/login/wechat') {
return { feature: 'login', loginChannel: 'partner' };
}
if (path === '/partner/stores') return { feature: 'store_submit' };
return null;
}
if (verb === 'GET') {
if (path === '/catalog/products' || /^\/catalog\/products\/\d+$/.test(path)) {
return { feature: 'product_load' };
}
if (path === '/stores' || /^\/stores\/\d+$/.test(path)) {
return { feature: 'store_load' };
}
}
return null;
}
export function normalizeApiPath(url: string): string {
const raw = url.split('?')[0] || '/';
const stripped = raw.replace(/^\/api\/v1(?=\/|$)/, '') || '/';
if (stripped.length > 1 && stripped.endsWith('/')) return stripped.slice(0, -1);
return stripped || '/';
}
export type WecomDispatchGate = 'allow' | 'switch_off' | 'percent_drop';
/** 通知开关优先于审核通知百分比。notifyEnabled 为 null 表示该事件没有总开关。 */
export function decideWecomSend(args: {
notifyEnabled: boolean | null;
applyAuditPercent: boolean;
successPercent: number;
roll: number;
}): WecomDispatchGate {
if (args.notifyEnabled === false) return 'switch_off';
if (args.applyAuditPercent && !allowBySuccessPercent(args.successPercent, args.roll)) {
return 'percent_drop';
}
return 'allow';
}
+1
View File
@@ -416,3 +416,4 @@ export * from './wecom-report';
export * from './wecom-plugin';
export * from './shipping-address';
export * from './store-address';
export * from './api-access';