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
+1 -1
View File
@@ -19,7 +19,7 @@
| **settlement** | StorePayout, PartnerBill | jacy-dukang |
| **ops** | 只读聚合、ActivityPoster | jacy-dukang |
| **analytics** | LogUserAnalytics | jacy-dukang |
| **common** | CommonResource, CommonEvent, CommonTicket | jacy-dukang |
| **common** | CommonResource, CommonEvent, CommonTicket, ApiAccessPolicy | jacy-dukang |
| **integrations** | 无表 | jacy-dukang |
**log_***:`LogThirdParty` 由写入方 Module 负责(支付→trade,短信→iam/notify)。
+19
View File
@@ -469,6 +469,25 @@ model SystemConfig {
@@map("system_config")
}
/// HQ 接口访问:成功百分比与企微通知总开关(v4.0.21)
model ApiAccessPolicy {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
kind String @db.VarChar(16)
featureKey String @default("") @map("feature_key") @db.VarChar(32)
scopeType String @map("scope_type") @db.VarChar(16)
actorType String @default("") @map("actor_type") @db.VarChar(16)
actorId BigInt @default(0) @map("actor_id") @db.UnsignedBigInt
successPercent Int? @map("success_percent")
errorKind String? @map("error_kind") @db.VarChar(32)
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@unique([kind, featureKey, scopeType, actorType, actorId], map: "api_access_policy_key")
@@index([kind, scopeType])
@@map("api_access_policy")
}
/// 企业微信智能机器人(HQ 可创建多实例,长连接)
model WecomBot {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+2
View File
@@ -21,6 +21,7 @@ import { CityScopeModule } from './modules/city-scope/city-scope.module';
import { CommonModule } from './modules/common/common.module';
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
import { SystemConfigModule } from './common/system-config/system-config.module';
import { ApiAccessModule } from './common/api-access/api-access.module';
import { TestWhitelistModule } from './common/test-whitelist/test-whitelist.module';
import { DevPlanModule } from './modules/dev-plan/dev-plan.module';
import { CallbacksModule } from './callbacks/callbacks.module';
@@ -37,6 +38,7 @@ import { RequestIdMiddleware } from './common/logging/request-id.middleware';
},
}),
PrismaModule,
ApiAccessModule,
SystemConfigModule,
TestWhitelistModule,
GeoModule,
@@ -1,5 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { RedisModule } from '../redis/redis.module';
import { ApiAccessModule } from '../api-access/api-access.module';
import { AlertService } from './alert.service';
import { PayRedeemAnomalyService } from './pay-redeem-anomaly.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
@@ -9,7 +10,7 @@ import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-
*/
@Global()
@Module({
imports: [RedisModule],
imports: [RedisModule, ApiAccessModule],
providers: [WecomMessagePushService, AlertService, PayRedeemAnomalyService],
exports: [WecomMessagePushService, AlertService, PayRedeemAnomalyService],
})
@@ -0,0 +1,19 @@
import { HttpException } from '@nestjs/common';
import {
API_ACCESS_ERROR_LABELS,
API_ACCESS_ERROR_STATUS,
type ApiAccessErrorKind,
} from '@dukang/shared-types';
export class ApiAccessDeniedException extends HttpException {
constructor(errorKind: ApiAccessErrorKind) {
super(
{
message: API_ACCESS_ERROR_LABELS[errorKind],
reason: errorKind,
apiAccessDenied: true,
},
API_ACCESS_ERROR_STATUS[errorKind],
);
}
}
@@ -0,0 +1,69 @@
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common';
import type { ApiAccessSubject } from '@dukang/domain';
import { Observable } from 'rxjs';
import { ApiAccessDeniedException } from './api-access.exception';
import { ApiAccessService } from './api-access.service';
type AccessRequest = {
method?: string;
url?: string;
originalUrl?: string;
body?: { phone?: unknown };
user?: { actorType?: string; actorId?: bigint | string };
};
@Injectable()
export class ApiAccessInterceptor implements NestInterceptor {
private readonly logger = new Logger(ApiAccessInterceptor.name);
constructor(private readonly apiAccess: ApiAccessService) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
if (context.getType() !== 'http') return next.handle();
const req = context.switchToHttp().getRequest<AccessRequest>();
const hit = this.apiAccess.classify(req.method || 'GET', req.originalUrl || req.url || '');
if (!hit) return next.handle();
try {
const subject = await this.resolveSubject(req, hit.loginChannel);
const decision = await this.apiAccess.evaluateHttp(hit.feature, subject);
if (!decision.allow) {
this.logger.log(`api access denied feature=${hit.feature} error=${decision.errorKind}`);
throw new ApiAccessDeniedException(decision.errorKind);
}
} catch (error) {
if (error instanceof ApiAccessDeniedException) throw error;
this.logger.warn(
`api access check skipped: ${error instanceof Error ? error.message : String(error)}`,
);
}
return next.handle();
}
private async resolveSubject(
req: AccessRequest,
loginChannel?: 'user' | 'store' | 'partner',
): Promise<ApiAccessSubject> {
if (loginChannel) {
const phone = typeof req.body?.phone === 'string' ? req.body.phone : '';
if (phone.trim()) return this.apiAccess.findLoginSubject(loginChannel, phone);
}
return subjectFromUser(req.user);
}
}
function subjectFromUser(user: AccessRequest['user']): ApiAccessSubject {
if (!user?.actorId || !user.actorType) return {};
const id = user.actorId.toString();
if (user.actorType === 'USER') return { userId: id };
if (user.actorType === 'STORE' || user.actorType === 'PARTNER' || user.actorType === 'HQ') {
return { accountType: user.actorType, accountId: id };
}
return {};
}
@@ -0,0 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ApiAccessInterceptor } from './api-access.interceptor';
import { ApiAccessService } from './api-access.service';
@Global()
@Module({
providers: [ApiAccessService, { provide: APP_INTERCEPTOR, useClass: ApiAccessInterceptor }],
exports: [ApiAccessService],
})
export class ApiAccessModule {}
@@ -0,0 +1,650 @@
import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
allowBySuccessPercent,
classifyApiAccessRoute,
decideWecomSend,
resolveApiAccessPercent,
type ApiAccessPercentRule,
type ApiAccessSubject,
type WecomDispatchGate,
} from '@dukang/domain';
import {
API_ACCESS_ACCOUNT_ACTOR_TYPES,
API_ACCESS_AUDIT_NOTIFY_EVENT,
API_ACCESS_DEFAULT_ERROR_KIND,
API_ACCESS_ERROR_KINDS,
API_ACCESS_NOTIFY_BY_EVENT,
API_ACCESS_NOTIFY_KEYS,
API_ACCESS_NOTIFY_LABELS,
API_ACCESS_PERCENT_FEATURE_LABELS,
API_ACCESS_PERCENT_FEATURES,
isApiAccessErrorKind,
isApiAccessNotifyKey,
isApiAccessPercentFeature,
type ApiAccessActorHit,
type ApiAccessActorType,
type ApiAccessErrorKind,
type ApiAccessFormResponse,
type ApiAccessGlobalItem,
type ApiAccessNotifyItem,
type ApiAccessOverrideCreate,
type ApiAccessPercentFeature,
type ApiAccessPolicyDto,
} from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module';
import { parseBigIntParam } from '../parse-bigint';
const CACHE_MS = 5000;
type PolicyRow = {
id: bigint;
kind: string;
featureKey: string;
scopeType: string;
actorType: string;
actorId: bigint;
successPercent: number | null;
errorKind: string | null;
enabled: boolean;
};
@Injectable()
export class ApiAccessService implements OnModuleInit {
private readonly logger = new Logger(ApiAccessService.name);
private ensured = false;
private missingLogged = false;
private cache: { at: number; percents: ApiAccessPercentRule[]; notifies: Map<string, boolean> } | null =
null;
constructor(private readonly prisma: PrismaService) {}
async onModuleInit(): Promise<void> {
try {
await this.ensureDefaults();
} catch (error) {
if (!this.noteMissing(error)) {
this.logger.warn(
`api access ensureDefaults failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
async getForm(): Promise<ApiAccessFormResponse> {
await this.adminReady();
const rows = await this.prisma.apiAccessPolicy.findMany({ orderBy: { id: 'asc' } });
const percents = API_ACCESS_PERCENT_FEATURES.map((feature) => {
const row = rows.find(
(item) =>
item.kind === 'percent' &&
item.scopeType === 'global' &&
item.featureKey === feature &&
item.actorId === BigInt(0),
);
return this.toDto(row, feature);
});
const overrides = rows.filter((item) => item.kind === 'percent' && item.scopeType !== 'global');
const labels = await this.labelsFor(overrides);
const notifies = API_ACCESS_NOTIFY_KEYS.map((feature) => {
const row = rows.find(
(item) => item.kind === 'notify' && item.scopeType === 'global' && item.featureKey === feature,
);
return this.toNotifyDto(row, feature);
});
return {
percents,
overrides: overrides.map((row) => this.toDto(row, null, labels)),
notifies,
};
}
async updateGlobals(items: ApiAccessGlobalItem[]): Promise<ApiAccessFormResponse> {
await this.adminReady();
if (!Array.isArray(items) || items.length !== API_ACCESS_PERCENT_FEATURES.length) {
throw new BadRequestException('需同时提交五个功能的成功百分比');
}
const seen = new Set<string>();
for (const item of items) {
if (!isApiAccessPercentFeature(item.featureKey) || seen.has(item.featureKey)) {
throw new BadRequestException('功能无效');
}
seen.add(item.featureKey);
const percent = this.assertPercent(item.successPercent);
const errorKind = this.assertErrorKind(item.errorKind);
await this.prisma.apiAccessPolicy.upsert({
where: {
kind_featureKey_scopeType_actorType_actorId: {
kind: 'percent',
featureKey: item.featureKey,
scopeType: 'global',
actorType: '',
actorId: BigInt(0),
},
},
create: {
kind: 'percent',
featureKey: item.featureKey,
scopeType: 'global',
actorType: '',
actorId: BigInt(0),
successPercent: percent,
errorKind,
enabled: true,
},
update: { successPercent: percent, errorKind },
});
}
this.invalidate();
return this.getForm();
}
async updateNotifies(items: ApiAccessNotifyItem[]): Promise<ApiAccessFormResponse> {
await this.adminReady();
if (!Array.isArray(items) || items.length !== API_ACCESS_NOTIFY_KEYS.length) {
throw new BadRequestException('需同时提交全部通知开关');
}
const seen = new Set<string>();
for (const item of items) {
if (!isApiAccessNotifyKey(item.featureKey) || seen.has(item.featureKey)) {
throw new BadRequestException('通知开关无效');
}
seen.add(item.featureKey);
if (typeof item.enabled !== 'boolean') throw new BadRequestException('开关须为布尔值');
await this.prisma.apiAccessPolicy.upsert({
where: {
kind_featureKey_scopeType_actorType_actorId: {
kind: 'notify',
featureKey: item.featureKey,
scopeType: 'global',
actorType: '',
actorId: BigInt(0),
},
},
create: {
kind: 'notify',
featureKey: item.featureKey,
scopeType: 'global',
actorType: '',
actorId: BigInt(0),
enabled: item.enabled,
},
update: { enabled: item.enabled },
});
}
this.invalidate();
return this.getForm();
}
async createOverride(dto: ApiAccessOverrideCreate): Promise<ApiAccessFormResponse> {
await this.adminReady();
const actorId = parseBigIntParam(dto.actorId, '对象');
if (actorId === BigInt(0)) throw new BadRequestException('对象无效');
const featureKey: string = dto.featureKey ?? '';
if (featureKey !== '' && !isApiAccessPercentFeature(featureKey)) {
throw new BadRequestException('功能无效');
}
const percent = this.assertPercent(dto.successPercent);
const errorKind = this.assertErrorKind(dto.errorKind);
if (dto.scopeType === 'user') {
if (dto.actorType !== 'USER') throw new BadRequestException('用户覆盖只能选择 C 端用户');
const user = await this.prisma.user.findUnique({ where: { id: actorId }, select: { id: true } });
if (!user) throw new BadRequestException('用户不存在');
} else if (dto.scopeType === 'account') {
if (!(API_ACCESS_ACCOUNT_ACTOR_TYPES as readonly string[]).includes(dto.actorType)) {
throw new BadRequestException('账户类型无效');
}
const exists = await this.accountExists(dto.actorType, actorId);
if (!exists) throw new BadRequestException('账户不存在');
} else {
throw new BadRequestException('范围无效');
}
try {
await this.prisma.apiAccessPolicy.create({
data: {
kind: 'percent',
featureKey,
scopeType: dto.scopeType,
actorType: dto.actorType,
actorId,
successPercent: percent,
errorKind,
enabled: true,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new BadRequestException('该对象在此功能上已有规则');
}
throw error;
}
this.invalidate();
return this.getForm();
}
async deleteOverride(id: bigint): Promise<ApiAccessFormResponse> {
await this.adminReady();
const row = await this.prisma.apiAccessPolicy.findUnique({ where: { id } });
if (!row || row.kind !== 'percent' || row.scopeType === 'global') {
throw new BadRequestException('只能删除账户或用户覆盖');
}
await this.prisma.apiAccessPolicy.delete({ where: { id } });
this.invalidate();
return this.getForm();
}
async searchActors(scope: string, q: string): Promise<ApiAccessActorHit[]> {
const keyword = q.trim();
if (!keyword) return [];
if (scope === 'user') {
const rows = await this.prisma.user.findMany({
where: {
OR: [
{ phone: { contains: keyword } },
{ nickname: { contains: keyword } },
{ userNo: { contains: keyword } },
],
},
take: 20,
orderBy: { id: 'desc' },
select: { id: true, phone: true, nickname: true, userNo: true },
});
return rows.map((row) => ({
actorType: 'USER' as const,
actorId: row.id.toString(),
phone: row.phone,
label: `用户 ${row.nickname || row.userNo}${row.phone ? `(${row.phone})` : ''}`,
}));
}
if (scope !== 'account') throw new BadRequestException('范围无效');
const [hq, stores, partners] = await Promise.all([
this.prisma.hqAccount.findMany({
where: {
OR: [{ phone: { contains: keyword } }, { name: { contains: keyword } }, { loginName: { contains: keyword } }],
},
take: 10,
orderBy: { id: 'desc' },
select: { id: true, phone: true, name: true },
}),
this.prisma.storeAccount.findMany({
where: { OR: [{ phone: { contains: keyword } }, { name: { contains: keyword } }] },
take: 10,
orderBy: { id: 'desc' },
select: { id: true, phone: true, name: true },
}),
this.prisma.partnerAccount.findMany({
where: {
OR: [
{ phone: { contains: keyword } },
{ name: { contains: keyword } },
{ companyName: { contains: keyword } },
],
},
take: 10,
orderBy: { id: 'desc' },
select: { id: true, phone: true, name: true, companyName: true },
}),
]);
return [
...hq.map((row) => this.accountHit('HQ', row.id, `总部 ${row.name}`, row.phone)),
...stores.map((row) => this.accountHit('STORE', row.id, `门店 ${row.name}`, row.phone)),
...partners.map((row) =>
this.accountHit('PARTNER', row.id, `合伙人 ${row.companyName || row.name}`, row.phone),
),
].slice(0, 20);
}
async evaluateHttp(
feature: ApiAccessPercentFeature,
subject: ApiAccessSubject,
): Promise<{ allow: boolean; errorKind: ApiAccessErrorKind }> {
try {
const rules = await this.loadPercentRules();
const resolved = resolveApiAccessPercent(rules, feature, subject);
const errorKind = isApiAccessErrorKind(resolved.errorKind)
? resolved.errorKind
: API_ACCESS_DEFAULT_ERROR_KIND;
return {
allow: allowBySuccessPercent(resolved.successPercent, Math.random() * 100),
errorKind,
};
} catch (error) {
if (this.noteMissing(error)) {
return { allow: true, errorKind: API_ACCESS_DEFAULT_ERROR_KIND };
}
throw error;
}
}
async isNotifySwitchOff(eventKey: string): Promise<boolean> {
const notifyKey = API_ACCESS_NOTIFY_BY_EVENT[eventKey];
if (!notifyKey) return false;
try {
const notifies = await this.loadNotifyMap();
return notifies.get(notifyKey) === false;
} catch (error) {
if (!this.noteMissing(error)) {
this.logger.warn(`api access notify switch read failed: ${error instanceof Error ? error.message : String(error)}`);
}
return false;
}
}
async gateWecom(eventKey: string, subject: ApiAccessSubject = {}): Promise<WecomDispatchGate> {
try {
const notifyKey = API_ACCESS_NOTIFY_BY_EVENT[eventKey] ?? null;
const notifies = await this.loadNotifyMap();
const notifyEnabled = notifyKey ? (notifies.get(notifyKey) ?? true) : null;
const applyAuditPercent = eventKey === API_ACCESS_AUDIT_NOTIFY_EVENT;
let successPercent = 100;
if (applyAuditPercent && notifyEnabled !== false) {
const resolved = resolveApiAccessPercent(await this.loadPercentRules(), 'audit_notify', subject);
successPercent = resolved.successPercent;
}
return decideWecomSend({
notifyEnabled,
applyAuditPercent: applyAuditPercent && notifyEnabled !== false,
successPercent,
roll: Math.random() * 100,
});
} catch (error) {
if (!this.noteMissing(error)) {
this.logger.warn(`api access wecom gate failed: ${error instanceof Error ? error.message : String(error)}`);
}
return 'allow';
}
}
async findLoginSubject(
channel: 'user' | 'store' | 'partner',
phone: string,
): Promise<ApiAccessSubject> {
const normalized = phone.trim();
if (!normalized) return {};
try {
if (channel === 'user') {
const user = await this.prisma.user.findUnique({
where: { phone: normalized },
select: { id: true },
});
return user ? { userId: user.id.toString() } : {};
}
if (channel === 'store') {
const row = await this.prisma.storeAccount.findUnique({
where: { phone: normalized },
select: { id: true },
});
return row ? { accountType: 'STORE', accountId: row.id.toString() } : {};
}
const row = await this.prisma.partnerAccount.findUnique({
where: { phone: normalized },
select: { id: true },
});
return row ? { accountType: 'PARTNER', accountId: row.id.toString() } : {};
} catch (error) {
if (this.noteMissing(error)) return {};
throw error;
}
}
classify(method: string, url: string) {
return classifyApiAccessRoute(method, url);
}
private async adminReady(): Promise<void> {
try {
await this.ensureDefaults();
} catch (error) {
if (this.noteMissing(error)) {
throw new BadRequestException('api_access_policy 表未就绪,请在 server/dukang-api 执行 npx prisma db push');
}
throw error;
}
}
private async ensureDefaults(): Promise<void> {
if (this.ensured) return;
for (const feature of API_ACCESS_PERCENT_FEATURES) {
await this.prisma.apiAccessPolicy.upsert({
where: {
kind_featureKey_scopeType_actorType_actorId: {
kind: 'percent',
featureKey: feature,
scopeType: 'global',
actorType: '',
actorId: BigInt(0),
},
},
create: {
kind: 'percent',
featureKey: feature,
scopeType: 'global',
actorType: '',
successPercent: 100,
errorKind: API_ACCESS_DEFAULT_ERROR_KIND,
enabled: true,
},
update: {},
});
}
for (const feature of API_ACCESS_NOTIFY_KEYS) {
await this.prisma.apiAccessPolicy.upsert({
where: {
kind_featureKey_scopeType_actorType_actorId: {
kind: 'notify',
featureKey: feature,
scopeType: 'global',
actorType: '',
actorId: BigInt(0),
},
},
create: {
kind: 'notify',
featureKey: feature,
scopeType: 'global',
actorType: '',
enabled: true,
},
update: {},
});
}
this.ensured = true;
}
private async loadPercentRules(): Promise<ApiAccessPercentRule[]> {
const cached = this.readCache();
if (cached) return cached.percents;
await this.ensureDefaults();
const rows = await this.prisma.apiAccessPolicy.findMany({ where: { kind: 'percent' } });
const percents = rows.map((row) => this.toRule(row));
const notifies = await this.readNotifyRows();
this.cache = { at: Date.now(), percents, notifies };
return percents;
}
private async loadNotifyMap(): Promise<Map<string, boolean>> {
const cached = this.readCache();
if (cached) return cached.notifies;
await this.loadPercentRules();
return this.cache?.notifies ?? new Map();
}
private async readNotifyRows(): Promise<Map<string, boolean>> {
const rows = await this.prisma.apiAccessPolicy.findMany({
where: { kind: 'notify', scopeType: 'global' },
});
return new Map(rows.map((row) => [row.featureKey, row.enabled]));
}
private readCache() {
if (!this.cache) return null;
if (Date.now() - this.cache.at > CACHE_MS) return null;
return this.cache;
}
private invalidate(): void {
this.cache = null;
this.ensured = true;
}
private toRule(row: PolicyRow): ApiAccessPercentRule {
return {
featureKey: row.featureKey,
scopeType: row.scopeType === 'account' || row.scopeType === 'user' ? row.scopeType : 'global',
actorType: row.actorType,
actorId: row.actorId.toString(),
successPercent: row.successPercent ?? 100,
errorKind: row.errorKind || API_ACCESS_DEFAULT_ERROR_KIND,
};
}
private toDto(
row: PolicyRow | undefined,
feature: ApiAccessPercentFeature | null,
labels?: Map<string, string>,
): ApiAccessPolicyDto {
const featureKey = row?.featureKey || feature || '';
return {
id: row?.id.toString() ?? '',
featureKey: featureKey ? featureKey : null,
featureLabel: isApiAccessPercentFeature(featureKey)
? API_ACCESS_PERCENT_FEATURE_LABELS[featureKey]
: '全部功能',
scopeType: row?.scopeType === 'account' || row?.scopeType === 'user' ? row.scopeType : 'global',
actorType: this.actorTypeOf(row?.actorType),
actorId: row && row.actorId !== BigInt(0) ? row.actorId.toString() : null,
actorLabel: row ? (labels?.get(this.labelKey(row)) ?? null) : null,
successPercent: row?.successPercent ?? 100,
errorKind: isApiAccessErrorKind(row?.errorKind || '')
? (row?.errorKind as ApiAccessErrorKind)
: API_ACCESS_DEFAULT_ERROR_KIND,
enabled: row?.enabled ?? true,
};
}
private toNotifyDto(row: PolicyRow | undefined, feature: (typeof API_ACCESS_NOTIFY_KEYS)[number]): ApiAccessPolicyDto {
return {
id: row?.id.toString() ?? '',
featureKey: feature,
featureLabel: API_ACCESS_NOTIFY_LABELS[feature],
scopeType: 'global',
actorType: null,
actorId: null,
actorLabel: null,
successPercent: null,
errorKind: null,
enabled: row?.enabled ?? true,
};
}
private async labelsFor(rows: PolicyRow[]): Promise<Map<string, string>> {
const ids = (type: string) => rows.filter((row) => row.actorType === type).map((row) => row.actorId);
const load = async <T>(type: string, query: (actorIds: bigint[]) => Promise<T[]>): Promise<T[]> => {
const actorIds = ids(type);
return actorIds.length ? query(actorIds) : [];
};
const [users, stores, partners, hq] = await Promise.all([
load('USER', (actorIds) =>
this.prisma.user.findMany({
where: { id: { in: actorIds } },
select: { id: true, phone: true, nickname: true, userNo: true },
}),
),
load('STORE', (actorIds) =>
this.prisma.storeAccount.findMany({
where: { id: { in: actorIds } },
select: { id: true, phone: true, name: true },
}),
),
load('PARTNER', (actorIds) =>
this.prisma.partnerAccount.findMany({
where: { id: { in: actorIds } },
select: { id: true, phone: true, name: true, companyName: true },
}),
),
load('HQ', (actorIds) =>
this.prisma.hqAccount.findMany({
where: { id: { in: actorIds } },
select: { id: true, phone: true, name: true },
}),
),
]);
const map = new Map<string, string>();
for (const row of users) {
map.set(`USER:${row.id}`, `用户 ${row.nickname || row.userNo}${row.phone ? `(${row.phone})` : ''}`);
}
for (const row of stores) map.set(`STORE:${row.id}`, `门店 ${row.name}(${row.phone})`);
for (const row of partners) {
map.set(`PARTNER:${row.id}`, `合伙人 ${row.companyName || row.name}(${row.phone})`);
}
for (const row of hq) map.set(`HQ:${row.id}`, `总部 ${row.name}(${row.phone})`);
return map;
}
private labelKey(row: PolicyRow): string {
return `${row.actorType}:${row.actorId}`;
}
private actorTypeOf(value: string | undefined): ApiAccessActorType | null {
if (value === 'USER' || value === 'STORE' || value === 'PARTNER' || value === 'HQ') return value;
return null;
}
private accountHit(
actorType: 'HQ' | 'STORE' | 'PARTNER',
id: bigint,
name: string,
phone: string,
): ApiAccessActorHit {
return {
actorType,
actorId: id.toString(),
phone,
label: `${name}(${phone})`,
};
}
private async accountExists(actorType: string, actorId: bigint): Promise<boolean> {
if (actorType === 'HQ') {
const row = await this.prisma.hqAccount.findUnique({ where: { id: actorId }, select: { id: true } });
return !!row;
}
if (actorType === 'STORE') {
const row = await this.prisma.storeAccount.findUnique({ where: { id: actorId }, select: { id: true } });
return !!row;
}
if (actorType === 'PARTNER') {
const row = await this.prisma.partnerAccount.findUnique({ where: { id: actorId }, select: { id: true } });
return !!row;
}
return false;
}
private assertPercent(value: unknown): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 100) {
throw new BadRequestException('成功百分比须为 0–100 的整数');
}
return value;
}
private assertErrorKind(value: unknown): ApiAccessErrorKind {
if (typeof value !== 'string' || !isApiAccessErrorKind(value)) {
throw new BadRequestException('失败文案无效');
}
if (!(API_ACCESS_ERROR_KINDS as readonly string[]).includes(value)) {
throw new BadRequestException('失败文案无效');
}
return value;
}
private noteMissing(error: unknown): boolean {
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false;
if (error.code !== 'P2021' && error.code !== 'P2022') return false;
if (!this.missingLogged) {
this.missingLogged = true;
this.logger.warn('api_access_policy 表不存在,接口访问限制未生效,请执行 npx prisma db push');
}
return true;
}
}
@@ -45,7 +45,10 @@ export class HttpExceptionFilter implements ExceptionFilter {
? res
: (res as { message?: string | string[] }).message || exception.message;
const msgText = Array.isArray(message) ? message.join(', ') : message;
if (status >= 500) {
const resObj =
typeof res === 'object' && res !== null ? (res as Record<string, unknown>) : null;
const apiAccessDenied = resObj?.apiAccessDenied === true;
if (status >= 500 && !apiAccessDenied) {
this.alert.notify({
level: 'P0',
category: 'api_error',
@@ -55,8 +58,6 @@ export class HttpExceptionFilter implements ExceptionFilter {
dedupeTtlSec: 120,
});
}
const resObj =
typeof res === 'object' && res !== null ? (res as Record<string, unknown>) : null;
const reason = (resObj?.reason as string | undefined) ?? null;
response.status(status).json({
code: status,
@@ -117,6 +117,7 @@ export const HqOperationAction = {
REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT',
DEPLOY_TRIGGER: 'DEPLOY_TRIGGER',
SYSTEM_CONFIG_UPDATE: 'SYSTEM_CONFIG_UPDATE',
API_ACCESS_UPDATE: 'API_ACCESS_UPDATE',
SYSTEM_CONFIG_SYNC_ENV: 'SYSTEM_CONFIG_SYNC_ENV',
SYSTEM_CONFIG_IMPORT_ENV: 'SYSTEM_CONFIG_IMPORT_ENV',
WECOM_ALERT_TEST: 'WECOM_ALERT_TEST',
@@ -260,6 +261,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回',
[HqOperationAction.DEPLOY_TRIGGER]: '触发系统发布',
[HqOperationAction.SYSTEM_CONFIG_UPDATE]: '更新系统配置',
[HqOperationAction.API_ACCESS_UPDATE]: '更新接口访问',
[HqOperationAction.SYSTEM_CONFIG_SYNC_ENV]: '同步系统配置到 env 文件',
[HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV]: '从当前环境导入配置',
[HqOperationAction.WECOM_ALERT_TEST]: '测试企微运营告警',
@@ -20,7 +20,9 @@ import {
type WecomPushTemplateDto,
type WecomTemplateEventKey,
} from '@dukang/shared-types';
import type { ApiAccessSubject } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { ApiAccessService } from '../../common/api-access/api-access.service';
import { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util';
import {
WECOM_PUSH_TEMPLATE_DEFAULTS,
@@ -62,7 +64,10 @@ type TemplateRow = {
export class WecomMessagePushService implements OnModuleInit {
private readonly logger = new Logger(WecomMessagePushService.name);
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly apiAccess: ApiAccessService,
) {}
async onModuleInit(): Promise<void> {
try {
@@ -248,12 +253,13 @@ export class WecomMessagePushService implements OnModuleInit {
async dispatchEvent(
eventKey: WecomTemplateEventKey,
vars: Record<string, string | number | null | undefined>,
options?: { applyMention?: boolean; handlePath?: string },
options?: { applyMention?: boolean; handlePath?: string; accessSubject?: ApiAccessSubject },
): Promise<number> {
try {
const content = await this.renderEventContent(eventKey, vars, options?.handlePath);
return await this.dispatchMarkdown(eventKey, content, {
applyMention: options?.applyMention ?? false,
accessSubject: options?.accessSubject,
});
} catch (e) {
this.logger.warn(
@@ -297,8 +303,13 @@ export class WecomMessagePushService implements OnModuleInit {
async dispatchMarkdown(
eventKey: WecomPushCondition,
content: string,
options?: { applyMention?: boolean },
options?: { applyMention?: boolean; accessSubject?: ApiAccessSubject },
): Promise<number> {
const gate = await this.apiAccess.gateWecom(eventKey, options?.accessSubject);
if (gate !== 'allow') {
this.logger.log(`skip wecom ${eventKey}: ${gate}`);
return 0;
}
const pushes = await this.listMatchingPushes(eventKey);
if (!pushes.length) return 0;
@@ -456,6 +467,9 @@ export class WecomMessagePushService implements OnModuleInit {
sample.vars,
sample.handlePath,
);
if (await this.apiAccess.isNotifySwitchOff(eventKey)) {
return { ok: false, message: '接口访问已关闭该通知', preview };
}
const sent = await this.dispatchMarkdown(eventKey, preview, { applyMention: false });
if (sent === 0) {
return {
@@ -0,0 +1,67 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import type { ApiAccessGlobalItem, ApiAccessNotifyItem, ApiAccessOverrideCreate } from '@dukang/shared-types';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqPermissionGuard, RequireHqPermissions } from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { parseBigIntParam } from '../../common/parse-bigint';
import { ApiAccessService } from '../../common/api-access/api-access.service';
@Controller('admin/api-access')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('api_access')
export class AdminApiAccessController {
constructor(private readonly apiAccess: ApiAccessService) {}
@Get()
getForm() {
return this.apiAccess.getForm();
}
@Get('actors')
searchActors(@Query('scope') scope?: string, @Query('q') q?: string) {
return this.apiAccess.searchActors(scope || '', q || '');
}
@Put('globals')
@HqOperation({
action: HqOperationAction.API_ACCESS_UPDATE,
refType: 'API_ACCESS',
batch: true,
includeBody: true,
})
updateGlobals(@Body() body: { items?: ApiAccessGlobalItem[] }) {
return this.apiAccess.updateGlobals(body?.items ?? []);
}
@Put('notifies')
@HqOperation({
action: HqOperationAction.API_ACCESS_UPDATE,
refType: 'API_ACCESS',
batch: true,
includeBody: true,
})
updateNotifies(@Body() body: { items?: ApiAccessNotifyItem[] }) {
return this.apiAccess.updateNotifies(body?.items ?? []);
}
@Post('overrides')
@HqOperation({
action: HqOperationAction.API_ACCESS_UPDATE,
refType: 'API_ACCESS',
includeBody: true,
})
createOverride(@Body() body: ApiAccessOverrideCreate) {
return this.apiAccess.createOverride(body);
}
@Delete('overrides/:id')
@HqOperation({
action: HqOperationAction.API_ACCESS_UPDATE,
refType: 'API_ACCESS',
refIdField: 'id',
})
deleteOverride(@Param('id') id: string) {
return this.apiAccess.deleteOverride(parseBigIntParam(id, '规则'));
}
}
@@ -921,7 +921,10 @@ export class AdminStoresService {
action: '新建',
storeId: store.id.toString(),
},
{ handlePath: `/stores?storeId=${store.id.toString()}` },
{
handlePath: `/stores?storeId=${store.id.toString()}`,
accessSubject: { accountType: 'HQ', accountId: actorId.toString() },
},
);
return this.detailStore(store.id, actorId);
@@ -65,6 +65,7 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
import { AdminDeployController } from './admin-deploy.controller';
import { AdminDeployService } from './admin-deploy.service';
import { AdminSystemConfigController } from './admin-system-config.controller';
import { AdminApiAccessController } from './admin-api-access.controller';
import { AdminWecomBotsController } from './admin-wecom-bots.controller';
import { AdminWecomBotsService } from './admin-wecom-bots.service';
import { AdminWecomMessagePushesController } from './admin-wecom-message-pushes.controller';
@@ -130,6 +131,7 @@ import { PartnerActivityPostersController } from './partner-activity-posters.con
AdminWechatBindingsController,
AdminHqPermissionsController,
AdminSystemConfigController,
AdminApiAccessController,
AdminWecomBotsController,
AdminWecomMessagePushesController,
AdminWecomApiPluginsController,
@@ -99,6 +99,7 @@ export class StoreService {
submitter?: string | null;
submitType: '新建' | '重提';
handlePath?: string;
partnerAccountId: bigint;
}) {
void this.wecomPush.dispatchEvent(
'store.audit_pending',
@@ -113,6 +114,7 @@ export class StoreService {
{
handlePath:
opts.handlePath || `/stores?auditStatus=PENDING&storeId=${opts.storeId.toString()}`,
accessSubject: { accountType: 'PARTNER', accountId: opts.partnerAccountId.toString() },
},
);
}
@@ -654,6 +656,7 @@ export class StoreService {
submitter,
partnerLabel,
submitType: '新建',
partnerAccountId,
});
}
@@ -825,6 +828,7 @@ export class StoreService {
submitter,
partnerLabel,
submitType: '重提',
partnerAccountId,
});
}
@@ -951,6 +955,7 @@ export class StoreService {
submitter,
partnerLabel,
submitType: '重提',
partnerAccountId,
});
}