feat: M0-03 feature flag whitelist + more config integration
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 37s

This commit is contained in:
WangDL 2026-05-22 23:00:47 +08:00
parent 8d5ff27a3c
commit a1ac07bf88
4 changed files with 22 additions and 3 deletions

View File

@ -0,0 +1 @@
ALTER TABLE FeatureFlag ADD COLUMN whitelist TEXT NULL;

View File

@ -860,6 +860,7 @@ model FeatureFlag {
enabled Boolean @default(false) enabled Boolean @default(false)
description String? @db.VarChar(500) description String? @db.VarChar(500)
rolloutPct Int @default(100) rolloutPct Int @default(100)
whitelist String? @db.Text
updatedBy String? @db.VarChar(100) updatedBy String? @db.VarChar(100)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@ -49,8 +49,13 @@ export class AppConfigController {
@Post('flags/:name') @Post('flags/:name')
@AdminRoles('SUPER_ADMIN' as AdminRole) @AdminRoles('SUPER_ADMIN' as AdminRole)
async toggleFlag(@Param('name') name: string, @Body() d: { enabled: boolean }, @Req() req: any) { async toggleFlag(@Param('name') name: string, @Body() d: { enabled?: boolean; whitelist?: string }, @Req() req: any) {
if (typeof d.enabled === 'boolean') {
await this.flags.setEnabled(name, d.enabled, req.adminUser?.email); await this.flags.setEnabled(name, d.enabled, req.adminUser?.email);
}
if (d.whitelist !== undefined) {
await this.flags.setWhitelist(name, d.whitelist);
}
return { success: true }; return { success: true };
} }
} }

View File

@ -12,7 +12,8 @@ export class FeatureFlagService {
private readonly redis: RedisService, private readonly redis: RedisService,
) {} ) {}
async isEnabled(name: string): Promise<boolean> { /** Check if flag is enabled, with optional user-level whitelist */
async isEnabled(name: string, userId?: string): Promise<boolean> {
try { try {
const cached = await this.redis.get(FF_PREFIX + name); const cached = await this.redis.get(FF_PREFIX + name);
if (cached !== null) return cached === '1'; if (cached !== null) return cached === '1';
@ -20,6 +21,12 @@ export class FeatureFlagService {
const flag = await this.prisma.featureFlag.findUnique({ where: { name } }); const flag = await this.prisma.featureFlag.findUnique({ where: { name } });
const enabled = flag?.enabled ?? false; const enabled = flag?.enabled ?? false;
// If user whitelist is set, only enabled for whitelisted users
if (enabled && flag?.whitelist) {
const whitelist = (flag.whitelist as string).split(',').map(s => s.trim());
if (!userId || !whitelist.includes(userId)) return false;
}
try { await this.redis.set(FF_PREFIX + name, enabled ? '1' : '0', FF_TTL); } catch {} try { await this.redis.set(FF_PREFIX + name, enabled ? '1' : '0', FF_TTL); } catch {}
return enabled; return enabled;
} }
@ -38,5 +45,10 @@ export class FeatureFlagService {
try { await this.redis.set(FF_PREFIX + name, enabled ? '1' : '0', FF_TTL); } catch {} try { await this.redis.set(FF_PREFIX + name, enabled ? '1' : '0', FF_TTL); } catch {}
} }
async setWhitelist(name: string, whitelist: string): Promise<void> {
await this.prisma.featureFlag.update({ where: { name }, data: { whitelist } });
try { await this.redis.del(FF_PREFIX + name); } catch {}
}
async getAll() { return this.prisma.featureFlag.findMany({ orderBy: { name: 'asc' } }) } async getAll() { return this.prisma.featureFlag.findMany({ orderBy: { name: 'asc' } }) }
} }