feat: M0-03 Config & Feature Flag — DB-backed config + Redis cache + Admin AAPI
Some checks failed
Deploy API Server / build-and-deploy (push) Has been cancelled

This commit is contained in:
WangDL 2026-05-22 22:36:32 +08:00
parent 4c4d14724a
commit 8d52214dd5
7 changed files with 227 additions and 0 deletions

View File

@ -0,0 +1,23 @@
CREATE TABLE "AppConfig" (
"id" VARCHAR(191) NOT NULL, "key" VARCHAR(100) NOT NULL, "value" TEXT NOT NULL,
"description" VARCHAR(500), "environment" VARCHAR(32) NOT NULL DEFAULT 'production',
"updatedBy" VARCHAR(100), "createdAt" DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
"updatedAt" DATETIME(3) NOT NULL,
UNIQUE INDEX "AppConfig_key_key"("key"), PRIMARY KEY ("id")
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE "FeatureFlag" (
"id" VARCHAR(191) NOT NULL, "name" VARCHAR(100) NOT NULL, "enabled" BOOLEAN NOT NULL DEFAULT false,
"description" VARCHAR(500), "rolloutPct" INTEGER NOT NULL DEFAULT 100,
"updatedBy" VARCHAR(100), "createdAt" DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
"updatedAt" DATETIME(3) NOT NULL,
UNIQUE INDEX "FeatureFlag_name_key"("name"), PRIMARY KEY ("id")
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE "ConfigChangeLog" (
"id" VARCHAR(191) NOT NULL, "entityType" VARCHAR(32) NOT NULL, "entityId" VARCHAR(100) NOT NULL,
"field" VARCHAR(100) NOT NULL, "oldValue" TEXT, "newValue" TEXT,
"changedBy" VARCHAR(100), "createdAt" DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX "ConfigChangeLog_entityType_entityId_idx"("entityType", "entityId"),
INDEX "ConfigChangeLog_createdAt_idx"("createdAt"), PRIMARY KEY ("id")
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -840,3 +840,43 @@ model AdminCostItem {
@@index([category])
@@index([expiryDate])
}
model AppConfig {
id String @id @default(cuid())
key String @unique @db.VarChar(100)
value String @db.Text
description String? @db.VarChar(500)
environment String @default("production") @db.VarChar(32)
updatedBy String? @db.VarChar(100)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([key])
}
model FeatureFlag {
id String @id @default(cuid())
name String @unique @db.VarChar(100)
enabled Boolean @default(false)
description String? @db.VarChar(500)
rolloutPct Int @default(100)
updatedBy String? @db.VarChar(100)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
}
model ConfigChangeLog {
id String @id @default(cuid())
entityType String @db.VarChar(32)
entityId String @db.VarChar(100)
field String @db.VarChar(100)
oldValue String? @db.Text
newValue String? @db.Text
changedBy String? @db.VarChar(100)
createdAt DateTime @default(now())
@@index([entityType, entityId])
@@index([createdAt])
}

View File

@ -16,6 +16,7 @@ import { AuthModule } from './modules/auth/auth.module';
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module';
import { AdminUsersModule } from './modules/admin-users/admin-users.module';
import { AppConfigModule } from './modules/config/config.module';
import { AdminEventsModule } from './modules/admin-events/admin-events.module';
import { AdminKnowledgeModule } from './modules/admin-knowledge/admin-knowledge.module';
import { AdminCostsModule } from './modules/admin-costs/admin-costs.module';
@ -97,6 +98,7 @@ import appleConfig from './config/apple.config';
AdminAuthModule,
AdminDashboardModule,
AdminUsersModule,
AppConfigModule,
AdminEventsModule,
AdminKnowledgeModule,
AdminCostsModule,

View File

@ -0,0 +1,56 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { AppConfigService } from './config.service';
import { FeatureFlagService } from './feature-flag.service';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
import { AdminRoles } from '../../common/decorators/admin-roles.decorator';
import type { AdminRole } from '../../common/types/admin-role.enum';
@ApiTags('admin-config')
@Controller('admin-api/config')
@UseGuards(AdminAuthGuard, AdminRolesGuard)
@ApiBearerAuth()
export class AppConfigController {
constructor(
private readonly config: AppConfigService,
private readonly flags: FeatureFlagService,
) {}
@Get()
@AdminRoles('SUPER_ADMIN' as AdminRole)
@ApiOperation({ summary: '配置列表' })
async list() { return { configs: await this.config.getAll(), flags: await this.flags.getAll() } }
@Post()
@AdminRoles('SUPER_ADMIN' as AdminRole)
async create(@Body() d: { key: string; value: string; description?: string }, @Req() req: any) {
await this.config.set(d.key, d.value, req.adminUser?.email);
return { success: true };
}
@Patch(':key')
@AdminRoles('SUPER_ADMIN' as AdminRole)
async update(@Param('key') key: string, @Body() d: { value: string }, @Req() req: any) {
await this.config.set(key, d.value, req.adminUser?.email);
return { success: true };
}
@Delete(':key')
@AdminRoles('SUPER_ADMIN' as AdminRole)
async remove(@Param('key') key: string) {
await this.config.delete(key);
return { success: true };
}
@Get('changelog')
@AdminRoles('SUPER_ADMIN' as AdminRole)
async changelog() { return this.config.getChangeLogs(); }
@Post('flags/:name')
@AdminRoles('SUPER_ADMIN' as AdminRole)
async toggleFlag(@Param('name') name: string, @Body() d: { enabled: boolean }, @Req() req: any) {
await this.flags.setEnabled(name, d.enabled, req.adminUser?.email);
return { success: true };
}
}

View File

@ -0,0 +1,16 @@
import { Module, Global } from '@nestjs/common';
import { AppConfigController } from './config.controller';
import { AppConfigService } from './config.service';
import { FeatureFlagService } from './feature-flag.service';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { RedisService } from '../../infrastructure/redis/redis.service';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
@Global()
@Module({
controllers: [AppConfigController],
providers: [AppAppAppConfigService, FeatureFlagService, PrismaService, RedisService, AdminAuthGuard, AdminRolesGuard],
exports: [AppAppAppConfigService, FeatureFlagService],
})
export class AppConfigModule {}

View File

@ -0,0 +1,48 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { RedisService } from '../../infrastructure/redis/redis.service';
const CACHE_PREFIX = 'config:';
const CACHE_TTL = 60; // seconds
@Injectable()
export class AppConfigService {
private readonly logger = new Logger(ConfigService.name);
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
async get(key: string, defaultValue?: string): Promise<string | null> {
try {
const cached = await this.redis.get(CACHE_PREFIX + key);
if (cached) return cached;
} catch {}
const config = await this.prisma.appConfig.findUnique({ where: { key } });
if (config) {
try { await this.redis.set(CACHE_PREFIX + key, config.value, CACHE_TTL); } catch {}
return config.value;
}
return defaultValue ?? null;
}
async set(key: string, value: string, updatedBy?: string): Promise<void> {
const existing = await this.prisma.appConfig.findUnique({ where: { key } });
if (existing) {
await this.prisma.appConfig.update({ where: { key }, data: { value, updatedBy } });
if (existing.value !== value) {
await this.prisma.configChangeLog.create({ data: { entityType: 'AppConfig', entityId: existing.id, field: 'value', oldValue: existing.value, newValue: value, changedBy: updatedBy } });
}
} else {
const created = await this.prisma.appConfig.create({ data: { key, value, updatedBy } });
await this.prisma.configChangeLog.create({ data: { entityType: 'AppConfig', entityId: created.id, field: 'value', newValue: value, changedBy: updatedBy } });
}
try { await this.redis.set(CACHE_PREFIX + key, value, CACHE_TTL); } catch {}
}
async getAll() { return this.prisma.appConfig.findMany({ orderBy: { key: 'asc' } }) }
async delete(key: string) { await this.prisma.appConfig.delete({ where: { key } }); try { await this.redis.del(CACHE_PREFIX + key) } catch {} }
async getChangeLogs(limit = 50) { return this.prisma.configChangeLog.findMany({ orderBy: { createdAt: 'desc' }, take: limit }) }
}

View File

@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { RedisService } from '../../infrastructure/redis/redis.service';
const FF_PREFIX = 'ff:';
const FF_TTL = 30;
@Injectable()
export class FeatureFlagService {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
async isEnabled(name: string): Promise<boolean> {
try {
const cached = await this.redis.get(FF_PREFIX + name);
if (cached !== null) return cached === '1';
} catch {}
const flag = await this.prisma.featureFlag.findUnique({ where: { name } });
const enabled = flag?.enabled ?? false;
try { await this.redis.set(FF_PREFIX + name, enabled ? '1' : '0', FF_TTL); } catch {}
return enabled;
}
async setEnabled(name: string, enabled: boolean, updatedBy?: string): Promise<void> {
const existing = await this.prisma.featureFlag.findUnique({ where: { name } });
if (existing) {
await this.prisma.featureFlag.update({ where: { name }, data: { enabled, updatedBy } });
if (existing.enabled !== enabled) {
await this.prisma.configChangeLog.create({ data: { entityType: 'FeatureFlag', entityId: existing.id, field: 'enabled', oldValue: String(existing.enabled), newValue: String(enabled), changedBy: updatedBy } });
}
} else {
const created = await this.prisma.featureFlag.create({ data: { name, enabled, updatedBy } });
await this.prisma.configChangeLog.create({ data: { entityType: 'FeatureFlag', entityId: created.id, field: 'enabled', newValue: String(enabled), changedBy: updatedBy } });
}
try { await this.redis.set(FF_PREFIX + name, enabled ? '1' : '0', FF_TTL); } catch {}
}
async getAll() { return this.prisma.featureFlag.findMany({ orderBy: { name: 'asc' } }) }
}