@@ -0,0 +1,99 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import {
|
||||
HQ_PERMISSION_CATALOG,
|
||||
HQ_ROLE_DEFAULT_PERMISSIONS,
|
||||
expandHqPermissionKeys,
|
||||
hasAnySystemSettingsPermission,
|
||||
type HqPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
import type { AuthUser } from './jwt-auth.guard';
|
||||
|
||||
export const HQ_PERMISSIONS_KEY = 'hq:permissions';
|
||||
export const RequireHqPermissions = (...keys: string[]) =>
|
||||
SetMetadata(HQ_PERMISSIONS_KEY, keys);
|
||||
/** 任意一项系统设置分组权限即可访问系统设置接口 */
|
||||
export const RequireAnySystemSettings = () =>
|
||||
SetMetadata(HQ_PERMISSIONS_KEY, ['__any_system_settings__']);
|
||||
|
||||
@Injectable()
|
||||
export class HqPermissionsResolver {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async resolveEffectiveKeys(actorId: bigint): Promise<HqPermissionKey[]> {
|
||||
const account = await this.prisma.hqAccount.findUnique({
|
||||
where: { id: actorId },
|
||||
select: { adminRole: true, status: true },
|
||||
});
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('HQ 账号不可用');
|
||||
}
|
||||
if (account.adminRole === 'SUPER_ADMIN') {
|
||||
return HQ_PERMISSION_CATALOG.map((p) => p.key);
|
||||
}
|
||||
|
||||
const [roleRows, userRows] = await Promise.all([
|
||||
this.prisma.hqRolePermission.findMany({
|
||||
where: { adminRole: account.adminRole },
|
||||
select: { permissionKey: true },
|
||||
}),
|
||||
this.prisma.hqAccountPermission.findMany({
|
||||
where: { hqAccountId: actorId },
|
||||
select: { permissionKey: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const roleKeys =
|
||||
roleRows.length > 0
|
||||
? roleRows.map((r) => r.permissionKey)
|
||||
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])];
|
||||
|
||||
return expandHqPermissionKeys([
|
||||
...roleKeys,
|
||||
...userRows.map((r) => r.permissionKey),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HqPermissionGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly resolver: HqPermissionsResolver,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req.user as AuthUser | undefined;
|
||||
if (!user || user.actorType !== 'HQ') {
|
||||
throw new ForbiddenException('需要 HQ 权限');
|
||||
}
|
||||
const keys = await this.resolver.resolveEffectiveKeys(user.actorId);
|
||||
req.hqPermissionKeys = keys;
|
||||
|
||||
const required =
|
||||
this.reflector.getAllAndOverride<string[]>(HQ_PERMISSIONS_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]) ?? [];
|
||||
|
||||
if (!required.length) return true;
|
||||
if (required.includes('__any_system_settings__')) {
|
||||
if (!hasAnySystemSettingsPermission(keys)) {
|
||||
throw new ForbiddenException('无系统设置权限');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!required.some((k) => keys.includes(k as HqPermissionKey))) {
|
||||
throw new ForbiddenException('权限不足');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -63,16 +63,23 @@ export class SystemConfigService implements OnModuleInit {
|
||||
return loadAppConfig(this.getMergedEnv());
|
||||
}
|
||||
|
||||
async getForm(): Promise<SystemConfigFormResponse> {
|
||||
async getForm(allowedGroups?: string[] | null): Promise<SystemConfigFormResponse> {
|
||||
if (!this.tableReady) {
|
||||
throw new Error('system_config 表未就绪,请在 server/dukang-api 执行 npx prisma db push');
|
||||
}
|
||||
const groups =
|
||||
allowedGroups == null
|
||||
? SYSTEM_CONFIG_GROUPS
|
||||
: SYSTEM_CONFIG_GROUPS.filter((g) => allowedGroups.includes(g.key));
|
||||
const allowedGroupSet = new Set(groups.map((g) => g.key));
|
||||
const fields = SYSTEM_CONFIG_FIELDS.filter((f) => allowedGroupSet.has(f.group));
|
||||
|
||||
const rows = await this.prisma.systemConfig.findMany();
|
||||
const dbMap = new Map(rows.map((r) => [r.configKey, r.value]));
|
||||
const values: Record<string, string> = {};
|
||||
const configuredSecrets: string[] = [];
|
||||
|
||||
for (const field of SYSTEM_CONFIG_FIELDS) {
|
||||
for (const field of fields) {
|
||||
const fromDb = dbMap.get(field.key);
|
||||
const fromEnv = process.env[field.key];
|
||||
const raw = fromDb ?? fromEnv ?? '';
|
||||
@@ -85,8 +92,8 @@ export class SystemConfigService implements OnModuleInit {
|
||||
}
|
||||
|
||||
return {
|
||||
groups: SYSTEM_CONFIG_GROUPS,
|
||||
fields: SYSTEM_CONFIG_FIELDS,
|
||||
groups,
|
||||
fields,
|
||||
values,
|
||||
configuredSecrets,
|
||||
envFilePath: resolveEnvFilePath(),
|
||||
@@ -95,18 +102,24 @@ export class SystemConfigService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
async update(dto: SystemConfigUpdateRequest): Promise<{
|
||||
async update(
|
||||
dto: SystemConfigUpdateRequest,
|
||||
allowedGroups?: string[] | null,
|
||||
): Promise<{
|
||||
updatedKeys: string[];
|
||||
requiresRestartKeys: string[];
|
||||
}> {
|
||||
const updatedKeys: string[] = [];
|
||||
const requiresRestartKeys: string[] = [];
|
||||
const overlay: Record<string, string> = {};
|
||||
const allowedGroupSet =
|
||||
allowedGroups == null ? null : new Set(allowedGroups);
|
||||
|
||||
for (const [key, rawValue] of Object.entries(dto.values ?? {})) {
|
||||
if (!SYSTEM_CONFIG_KEY_SET.has(key)) continue;
|
||||
const meta = getSystemConfigField(key);
|
||||
if (!meta) continue;
|
||||
if (allowedGroupSet && !allowedGroupSet.has(meta.group)) continue;
|
||||
|
||||
let value = String(rawValue ?? '').trim();
|
||||
if (meta.secret && (!value || value === SECRET_PLACEHOLDER)) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { verifyPassword } from '../../common/crypto/password.util';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { UserAddressService } from './user-address.service';
|
||||
import { ResourceService } from '../common/resource.service';
|
||||
import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard';
|
||||
|
||||
import type { User } from '@prisma/client';
|
||||
|
||||
@@ -65,6 +66,7 @@ export class AuthService {
|
||||
private readonly smsCodeStore: SmsCodeStore,
|
||||
private readonly userAddressService: UserAddressService,
|
||||
@Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService,
|
||||
private readonly hqPermissions: HqPermissionsResolver,
|
||||
) {}
|
||||
|
||||
private assertMobilePhone(phone: string) {
|
||||
@@ -1128,7 +1130,9 @@ export class AuthService {
|
||||
}
|
||||
if (actorType === 'HQ') {
|
||||
const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } });
|
||||
return serializeBigInt(account);
|
||||
if (!account) return null;
|
||||
const permissionKeys = await this.hqPermissions.resolveEffectiveKeys(actorId);
|
||||
return serializeBigInt({ ...account, permissionKeys });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
|
||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||
import { StoreMembershipService } from '../../common/guards/store-membership.service';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
HqPermissionsResolver,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
|
||||
@Module({
|
||||
@@ -59,6 +63,8 @@ import { CommonModule } from '../common/common.module';
|
||||
PartnerPrimaryGuard,
|
||||
PartnerPermissionGuard,
|
||||
ShopStoreGuard,
|
||||
HqPermissionsResolver,
|
||||
HqPermissionGuard,
|
||||
],
|
||||
exports: [
|
||||
AuthService,
|
||||
@@ -74,6 +80,8 @@ import { CommonModule } from '../common/common.module';
|
||||
PartnerPrimaryGuard,
|
||||
PartnerPermissionGuard,
|
||||
ShopStoreGuard,
|
||||
HqPermissionsResolver,
|
||||
HqPermissionGuard,
|
||||
],
|
||||
})
|
||||
export class IamModule {}
|
||||
|
||||
@@ -2,12 +2,17 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
||||
import {
|
||||
HQ_PERMISSION_CATALOG,
|
||||
HQ_ROLE_DEFAULT_PERMISSIONS,
|
||||
LEGACY_SYSTEM_SETTINGS_KEY,
|
||||
expandHqPermissionKeys,
|
||||
type HqPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
const VALID_PERMISSION_KEYS = new Set<string>(HQ_PERMISSION_CATALOG.map((p) => p.key));
|
||||
const VALID_PERMISSION_KEYS = new Set<string>([
|
||||
...HQ_PERMISSION_CATALOG.map((p) => p.key),
|
||||
LEGACY_SYSTEM_SETTINGS_KEY,
|
||||
]);
|
||||
|
||||
function assertPermissionKeys(keys: string[]) {
|
||||
const invalid = keys.filter((key) => !VALID_PERMISSION_KEYS.has(key));
|
||||
@@ -37,7 +42,7 @@ export class AdminHqPermissionsService {
|
||||
});
|
||||
const permissionKeys =
|
||||
rows.length > 0
|
||||
? rows.map((r) => r.permissionKey)
|
||||
? expandHqPermissionKeys(rows.map((r) => r.permissionKey))
|
||||
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[role] ?? [])];
|
||||
return { role, permissionKeys };
|
||||
}
|
||||
@@ -47,13 +52,14 @@ export class AdminHqPermissionsService {
|
||||
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
|
||||
}
|
||||
assertPermissionKeys(permissionKeys);
|
||||
const normalized = expandHqPermissionKeys(permissionKeys);
|
||||
const adminRole = role as 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE';
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.hqRolePermission.deleteMany({ where: { adminRole } }),
|
||||
...(permissionKeys.length
|
||||
...(normalized.length
|
||||
? [
|
||||
this.prisma.hqRolePermission.createMany({
|
||||
data: permissionKeys.map((permissionKey) => ({ adminRole, permissionKey })),
|
||||
data: normalized.map((permissionKey) => ({ adminRole, permissionKey })),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
@@ -84,7 +90,7 @@ export class AdminHqPermissionsService {
|
||||
select: { permissionKey: true },
|
||||
}),
|
||||
]);
|
||||
const userPermissionKeys = userPerms.map((p) => p.permissionKey);
|
||||
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
|
||||
const effectivePermissionKeys = [
|
||||
...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]),
|
||||
] as HqPermissionKey[];
|
||||
@@ -105,12 +111,13 @@ export class AdminHqPermissionsService {
|
||||
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
|
||||
}
|
||||
assertPermissionKeys(permissionKeys);
|
||||
const normalized = expandHqPermissionKeys(permissionKeys);
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.hqAccountPermission.deleteMany({ where: { hqAccountId: accountId } }),
|
||||
...(permissionKeys.length
|
||||
...(normalized.length
|
||||
? [
|
||||
this.prisma.hqAccountPermission.createMany({
|
||||
data: permissionKeys.map((permissionKey) => ({ hqAccountId: accountId, permissionKey })),
|
||||
data: normalized.map((permissionKey) => ({ hqAccountId: accountId, permissionKey })),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
import { Body, Controller, Get, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import type { SystemConfigUpdateRequest } from '@dukang/shared-types';
|
||||
import {
|
||||
SYSTEM_CONFIG_GROUP_PERMISSION,
|
||||
SYSTEM_SETTINGS_PERMISSION_KEYS,
|
||||
type HqPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
HqPermissionsResolver,
|
||||
RequireAnySystemSettings,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
||||
|
||||
@Controller('admin/system-config')
|
||||
@UseGuards(HqAuthGuard, SuperAdminGuard)
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
export class AdminSystemConfigController {
|
||||
constructor(private readonly systemConfig: SystemConfigService) {}
|
||||
constructor(
|
||||
private readonly systemConfig: SystemConfigService,
|
||||
private readonly permissions: HqPermissionsResolver,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
getForm() {
|
||||
return this.systemConfig.getForm();
|
||||
@RequireAnySystemSettings()
|
||||
async getForm(@CurrentUser() user: AuthUser) {
|
||||
const keys = await this.permissions.resolveEffectiveKeys(user.actorId);
|
||||
const allowedGroups = allowedConfigGroups(keys);
|
||||
return this.systemConfig.getForm(allowedGroups);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@RequireAnySystemSettings()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SYSTEM_CONFIG_UPDATE,
|
||||
refType: 'SYSTEM_CONFIG',
|
||||
@@ -24,11 +43,14 @@ export class AdminSystemConfigController {
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
update(@Body() dto: SystemConfigUpdateRequest) {
|
||||
return this.systemConfig.update(dto);
|
||||
async update(@CurrentUser() user: AuthUser, @Body() dto: SystemConfigUpdateRequest) {
|
||||
const keys = await this.permissions.resolveEffectiveKeys(user.actorId);
|
||||
const allowedGroups = allowedConfigGroups(keys);
|
||||
return this.systemConfig.update(dto, allowedGroups);
|
||||
}
|
||||
|
||||
@Post('sync-env')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SYSTEM_CONFIG_SYNC_ENV,
|
||||
refType: 'SYSTEM_CONFIG',
|
||||
@@ -39,6 +61,7 @@ export class AdminSystemConfigController {
|
||||
}
|
||||
|
||||
@Post('import-env')
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV,
|
||||
refType: 'SYSTEM_CONFIG',
|
||||
@@ -48,3 +71,12 @@ export class AdminSystemConfigController {
|
||||
return this.systemConfig.importFromProcessEnv();
|
||||
}
|
||||
}
|
||||
|
||||
function allowedConfigGroups(permissionKeys: HqPermissionKey[]): string[] | null {
|
||||
if (SYSTEM_SETTINGS_PERMISSION_KEYS.every((k) => permissionKeys.includes(k))) {
|
||||
return null; // 全部
|
||||
}
|
||||
return Object.entries(SYSTEM_CONFIG_GROUP_PERMISSION)
|
||||
.filter(([, perm]) => permissionKeys.includes(perm))
|
||||
.map(([group]) => group);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user