feat(ops): 总部可按百分比限制接口放行并开关企微通知
线上需要按账户、用户和功能控制登录与加载成功率,同时单独停发订单、核销和账单通知。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user