feat(ops): 总部可按百分比限制接口放行并开关企微通知
线上需要按账户、用户和功能控制登录与加载成功率,同时单独停发订单、核销和账单通知。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -416,3 +416,4 @@ export * from './wecom-report';
|
||||
export * from './wecom-plugin';
|
||||
export * from './shipping-address';
|
||||
export * from './store-address';
|
||||
export * from './api-access';
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/** HQ 接口访问:成功百分比功能(v4.0.21) */
|
||||
export const API_ACCESS_PERCENT_FEATURES = [
|
||||
'login',
|
||||
'product_load',
|
||||
'store_load',
|
||||
'store_submit',
|
||||
'audit_notify',
|
||||
] as const;
|
||||
|
||||
export type ApiAccessPercentFeature = (typeof API_ACCESS_PERCENT_FEATURES)[number];
|
||||
|
||||
export const API_ACCESS_PERCENT_FEATURE_LABELS: Record<ApiAccessPercentFeature, string> = {
|
||||
login: '登录',
|
||||
product_load: '商品加载',
|
||||
store_load: '门店加载',
|
||||
store_submit: '门店提交',
|
||||
audit_notify: '审核通知',
|
||||
};
|
||||
|
||||
/** 只控制企微是否发送,不拦截业务 */
|
||||
export const API_ACCESS_NOTIFY_KEYS = [
|
||||
'order_push',
|
||||
'redeem_push',
|
||||
'bill_push_winery',
|
||||
'bill_push_partner',
|
||||
'bill_push_store',
|
||||
'store_audit',
|
||||
'package_change',
|
||||
] as const;
|
||||
|
||||
export type ApiAccessNotifyKey = (typeof API_ACCESS_NOTIFY_KEYS)[number];
|
||||
|
||||
export const API_ACCESS_NOTIFY_LABELS: Record<ApiAccessNotifyKey, string> = {
|
||||
order_push: '订单推送',
|
||||
redeem_push: '核销推送',
|
||||
bill_push_winery: '酒厂账单推送',
|
||||
bill_push_partner: '城市合伙人账单推送',
|
||||
bill_push_store: '门店账单推送',
|
||||
store_audit: '门店审核',
|
||||
package_change: '套餐修改',
|
||||
};
|
||||
|
||||
/** 企微 eventKey → 通知总开关。未列出的事件不受开关影响 */
|
||||
export const API_ACCESS_NOTIFY_BY_EVENT: Record<string, ApiAccessNotifyKey> = {
|
||||
'order.paid': 'order_push',
|
||||
'redeem.success': 'redeem_push',
|
||||
'finance.winery_bill': 'bill_push_winery',
|
||||
'finance.partner_bill': 'bill_push_partner',
|
||||
'finance.store_bill': 'bill_push_store',
|
||||
'store.audit_pending': 'store_audit',
|
||||
'store.package_audit_pending': 'package_change',
|
||||
};
|
||||
|
||||
/** 审核通知百分比只作用在这一条企微事件上 */
|
||||
export const API_ACCESS_AUDIT_NOTIFY_EVENT = 'store.audit_pending';
|
||||
|
||||
export const API_ACCESS_ERROR_KINDS = [
|
||||
'network_load_failed',
|
||||
'request_error',
|
||||
'illegal_access',
|
||||
'wechat_service_error',
|
||||
] as const;
|
||||
|
||||
export type ApiAccessErrorKind = (typeof API_ACCESS_ERROR_KINDS)[number];
|
||||
|
||||
export const API_ACCESS_DEFAULT_ERROR_KIND: ApiAccessErrorKind = 'request_error';
|
||||
|
||||
export const API_ACCESS_ERROR_LABELS: Record<ApiAccessErrorKind, string> = {
|
||||
network_load_failed: '网络加载失败',
|
||||
request_error: '请求异常',
|
||||
illegal_access: '非法访问',
|
||||
wechat_service_error: '微信服务异常',
|
||||
};
|
||||
|
||||
export const API_ACCESS_ERROR_STATUS: Record<ApiAccessErrorKind, number> = {
|
||||
network_load_failed: 503,
|
||||
request_error: 503,
|
||||
illegal_access: 403,
|
||||
wechat_service_error: 503,
|
||||
};
|
||||
|
||||
export const API_ACCESS_ACTOR_TYPES = ['USER', 'STORE', 'PARTNER', 'HQ'] as const;
|
||||
export type ApiAccessActorType = (typeof API_ACCESS_ACTOR_TYPES)[number];
|
||||
|
||||
export const API_ACCESS_ACCOUNT_ACTOR_TYPES = ['STORE', 'PARTNER', 'HQ'] as const;
|
||||
export type ApiAccessAccountActorType = (typeof API_ACCESS_ACCOUNT_ACTOR_TYPES)[number];
|
||||
|
||||
export interface ApiAccessPolicyDto {
|
||||
id: string;
|
||||
featureKey: string | null;
|
||||
featureLabel: string;
|
||||
scopeType: 'global' | 'account' | 'user';
|
||||
actorType: ApiAccessActorType | null;
|
||||
actorId: string | null;
|
||||
actorLabel: string | null;
|
||||
successPercent: number | null;
|
||||
errorKind: ApiAccessErrorKind | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ApiAccessFormResponse {
|
||||
percents: ApiAccessPolicyDto[];
|
||||
overrides: ApiAccessPolicyDto[];
|
||||
notifies: ApiAccessPolicyDto[];
|
||||
}
|
||||
|
||||
export interface ApiAccessGlobalItem {
|
||||
featureKey: ApiAccessPercentFeature;
|
||||
successPercent: number;
|
||||
errorKind: ApiAccessErrorKind;
|
||||
}
|
||||
|
||||
export interface ApiAccessNotifyItem {
|
||||
featureKey: ApiAccessNotifyKey;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ApiAccessOverrideCreate {
|
||||
scopeType: 'account' | 'user';
|
||||
actorType: ApiAccessActorType;
|
||||
actorId: string;
|
||||
/** null = 该对象的全部百分比功能 */
|
||||
featureKey: ApiAccessPercentFeature | null;
|
||||
successPercent: number;
|
||||
errorKind: ApiAccessErrorKind;
|
||||
}
|
||||
|
||||
export interface ApiAccessActorHit {
|
||||
actorType: ApiAccessActorType;
|
||||
actorId: string;
|
||||
label: string;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
export function isApiAccessPercentFeature(value: string): value is ApiAccessPercentFeature {
|
||||
return (API_ACCESS_PERCENT_FEATURES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isApiAccessNotifyKey(value: string): value is ApiAccessNotifyKey {
|
||||
return (API_ACCESS_NOTIFY_KEYS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isApiAccessErrorKind(value: string): value is ApiAccessErrorKind {
|
||||
return (API_ACCESS_ERROR_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export const HQ_PERMISSION_CATALOG = [
|
||||
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
|
||||
{ key: 'system_settings_winery_bank', label: '酒厂银行账户', group: '系统设置' },
|
||||
{ key: 'system_settings_finance', label: '财务结算', group: '系统设置' },
|
||||
{ key: 'api_access', label: '接口访问', group: '系统设置' },
|
||||
] as const;
|
||||
|
||||
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
|
||||
@@ -76,6 +77,7 @@ export const HQ_DANGEROUS_PERMISSION_KEYS = [
|
||||
'orders_delete',
|
||||
'cities_delete',
|
||||
'store_categories_delete',
|
||||
'api_access',
|
||||
] as const satisfies readonly HqPermissionKey[];
|
||||
|
||||
export function isHqDangerousPermission(key: string): boolean {
|
||||
@@ -175,7 +177,7 @@ const OPS_STORE_KEYS: HqPermissionKey[] = [
|
||||
/** 开发者默认:除权限分配、HQ 账户外全部权限 */
|
||||
function developerDefaultPermissions(): HqPermissionKey[] {
|
||||
return HQ_PERMISSION_CATALOG.map((p) => p.key).filter(
|
||||
(k) => k !== 'hq_permissions' && k !== 'hq_accounts',
|
||||
(k) => k !== 'hq_permissions' && k !== 'hq_accounts' && k !== 'api_access',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,3 +36,4 @@ export * from './llm-config';
|
||||
export * from './knowledge-base';
|
||||
export * from './legal';
|
||||
export * from './dev-plan';
|
||||
export * from './api-access';
|
||||
|
||||
Reference in New Issue
Block a user