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
Some checks failed
Deploy API Server / build-and-deploy (push) Has been cancelled
This commit is contained in:
parent
4c4d14724a
commit
8d52214dd5
@ -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;
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
56
src/modules/config/config.controller.ts
Normal file
56
src/modules/config/config.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
16
src/modules/config/config.module.ts
Normal file
16
src/modules/config/config.module.ts
Normal 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 {}
|
||||
48
src/modules/config/config.service.ts
Normal file
48
src/modules/config/config.service.ts
Normal 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 }) }
|
||||
}
|
||||
42
src/modules/config/feature-flag.service.ts
Normal file
42
src/modules/config/feature-flag.service.ts
Normal 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' } }) }
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user