From 8d52214dd536b834b89da30b8d2755d08c5ca337 Mon Sep 17 00:00:00 2001 From: WangDL Date: Fri, 22 May 2026 22:36:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M0-03=20Config=20&=20Feature=20Flag=20?= =?UTF-8?q?=E2=80=94=20DB-backed=20config=20+=20Redis=20cache=20+=20Admin?= =?UTF-8?q?=20AAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 23 ++++++++ prisma/schema.prisma | 40 +++++++++++++ src/app.module.ts | 2 + src/modules/config/config.controller.ts | 56 +++++++++++++++++++ src/modules/config/config.module.ts | 16 ++++++ src/modules/config/config.service.ts | 48 ++++++++++++++++ src/modules/config/feature-flag.service.ts | 42 ++++++++++++++ 7 files changed, 227 insertions(+) create mode 100644 prisma/migrations/20260522223545_add_config_and_feature_flag/migration.sql create mode 100644 src/modules/config/config.controller.ts create mode 100644 src/modules/config/config.module.ts create mode 100644 src/modules/config/config.service.ts create mode 100644 src/modules/config/feature-flag.service.ts diff --git a/prisma/migrations/20260522223545_add_config_and_feature_flag/migration.sql b/prisma/migrations/20260522223545_add_config_and_feature_flag/migration.sql new file mode 100644 index 0000000..4b346e6 --- /dev/null +++ b/prisma/migrations/20260522223545_add_config_and_feature_flag/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 101d3f6..0fb19e6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/src/app.module.ts b/src/app.module.ts index 77d11a2..dc93509 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/modules/config/config.controller.ts b/src/modules/config/config.controller.ts new file mode 100644 index 0000000..a752a3c --- /dev/null +++ b/src/modules/config/config.controller.ts @@ -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 }; + } +} diff --git a/src/modules/config/config.module.ts b/src/modules/config/config.module.ts new file mode 100644 index 0000000..146982e --- /dev/null +++ b/src/modules/config/config.module.ts @@ -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 {} diff --git a/src/modules/config/config.service.ts b/src/modules/config/config.service.ts new file mode 100644 index 0000000..482b668 --- /dev/null +++ b/src/modules/config/config.service.ts @@ -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 { + 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 { + 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 }) } +} diff --git a/src/modules/config/feature-flag.service.ts b/src/modules/config/feature-flag.service.ts new file mode 100644 index 0000000..214cda7 --- /dev/null +++ b/src/modules/config/feature-flag.service.ts @@ -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 { + 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 { + 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' } }) } +}