From 406a5afa3322c3c8b9b256b57c048d0eb1ccbbba Mon Sep 17 00:00:00 2001 From: empty Date: Tue, 3 Feb 2026 22:26:47 +0800 Subject: [PATCH] feat(server): add Program and Award database models - Add Program and Award tables to Prisma schema - Update program-config.service to support database with JSON fallback - Update ProgramCard.vue display logic for dynamic teamName/performer - Add seed script to import data from JSON config Co-Authored-By: Claude Opus 4.5 --- .../src/components/ProgramCard.vue | 17 +- packages/server/package.json | 2 +- packages/server/prisma/schema.prisma | 32 ++- packages/server/prisma/seed.ts | 110 ++++++++ .../src/services/program-config.service.ts | 262 +++++++++++++++--- 5 files changed, 374 insertions(+), 49 deletions(-) create mode 100644 packages/server/prisma/seed.ts diff --git a/packages/client-mobile/src/components/ProgramCard.vue b/packages/client-mobile/src/components/ProgramCard.vue index 6b90d66..284b8dc 100644 --- a/packages/client-mobile/src/components/ProgramCard.vue +++ b/packages/client-mobile/src/components/ProgramCard.vue @@ -60,12 +60,21 @@ const programNumber = computed(() => { return num.toString().padStart(2, '0'); }); -// From 显示:部门·表演者 +// From 显示:根据数据动态显示 const fromDisplay = computed(() => { - if (props.performer) { - return `${props.teamName || ''}·${props.performer}`; + const team = props.teamName?.trim(); + const performer = props.performer?.trim(); + + if (team && performer) { + return `${team}·${performer}`; } - return props.teamName || 'The Performer'; + if (performer) { + return performer; + } + if (team) { + return team; + } + return '表演者'; }); // 当前选中奖项的备注(用于移动端引导) diff --git a/packages/server/package.json b/packages/server/package.json index f289791..a11292b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -11,7 +11,7 @@ "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", "db:push": "prisma db push", - "db:seed": "tsx src/scripts/seed.ts", + "db:seed": "tsx prisma/seed.ts", "test": "vitest", "test:load": "artillery run load-test/vote-load-test.yaml -e standard", "test:load:smoke": "artillery run load-test/vote-load-test.yaml -e smoke", diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index 124d2e8..f0a845f 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -11,7 +11,6 @@ datasource db { model User { id String @id @default(cuid()) name String @db.VarChar(100) - department String @db.VarChar(100) avatar String? @db.VarChar(512) birthYear Int? @map("birth_year") zodiac String? @db.VarChar(20) @@ -93,6 +92,37 @@ model DrawResult { @@map("draw_results") } +// Programs for voting +model Program { + id String @id @default(cuid()) + name String @db.VarChar(100) + teamName String @default("") @map("team_name") @db.VarChar(100) + performer String @default("") @db.VarChar(200) + order Int @default(0) + remark String? @db.Text + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([order]) + @@map("programs") +} + +// Awards for voting +model Award { + id String @id @default(cuid()) + name String @db.VarChar(100) + icon String @db.VarChar(20) + order Int @default(0) + remark String? @db.Text + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([order]) + @@map("awards") +} + // Draw sessions model DrawSession { id String @id @default(cuid()) diff --git a/packages/server/prisma/seed.ts b/packages/server/prisma/seed.ts new file mode 100644 index 0000000..048a1d4 --- /dev/null +++ b/packages/server/prisma/seed.ts @@ -0,0 +1,110 @@ +/** + * Prisma Seed Script + * Populates initial program and award data from JSON config + */ +import { PrismaClient } from '@prisma/client'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const prisma = new PrismaClient(); + +interface ProgramData { + id: string; + name: string; + teamName: string; + performer: string; + order: number; + remark: string; +} + +interface AwardData { + id: string; + name: string; + icon: string; + order: number; + remark: string; +} + +interface ConfigFile { + programs: ProgramData[]; + awards: AwardData[]; +} + +async function main() { + console.log('Starting seed...'); + + // Load config from JSON file + const configPath = path.join(__dirname, '../config/programs.json'); + + if (!fs.existsSync(configPath)) { + console.error('Config file not found:', configPath); + process.exit(1); + } + + const config: ConfigFile = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + + // Seed programs + console.log(`Seeding ${config.programs.length} programs...`); + for (const p of config.programs) { + await prisma.program.upsert({ + where: { id: p.id }, + create: { + id: p.id, + name: p.name, + teamName: p.teamName || '', + performer: p.performer || '', + order: p.order, + remark: p.remark || null, + isActive: true, + }, + update: { + name: p.name, + teamName: p.teamName || '', + performer: p.performer || '', + order: p.order, + remark: p.remark || null, + isActive: true, + }, + }); + console.log(` - ${p.name}`); + } + + // Seed awards + console.log(`Seeding ${config.awards.length} awards...`); + for (const a of config.awards) { + await prisma.award.upsert({ + where: { id: a.id }, + create: { + id: a.id, + name: a.name, + icon: a.icon, + order: a.order, + remark: a.remark || null, + isActive: true, + }, + update: { + name: a.name, + icon: a.icon, + order: a.order, + remark: a.remark || null, + isActive: true, + }, + }); + console.log(` - ${a.name}`); + } + + console.log('Seed completed!'); +} + +main() + .catch((e) => { + console.error('Seed failed:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/packages/server/src/services/program-config.service.ts b/packages/server/src/services/program-config.service.ts index 30b3268..0a65db7 100644 --- a/packages/server/src/services/program-config.service.ts +++ b/packages/server/src/services/program-config.service.ts @@ -1,26 +1,27 @@ /** * Program Configuration Service - * Loads program config from JSON file and provides API for management + * Loads program config from database with JSON file fallback */ import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { logger } from '../utils/logger'; +import { prisma } from '../utils/prisma'; import type { VotingProgram } from '@gala/shared/types'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -// Default config path +// Default config path (fallback) const CONFIG_PATH = path.join(__dirname, '../../config/programs.json'); export interface ProgramConfig { id: string; name: string; teamName: string; - performer?: string; // 表演者 + performer?: string; order: number; - remark?: string; // 节目备注 + remark?: string; } export interface AwardConfig { @@ -28,6 +29,7 @@ export interface AwardConfig { name: string; icon: string; order: number; + remark?: string; } export interface ProgramSettings { @@ -44,15 +46,85 @@ export interface ProgramConfigFile { class ProgramConfigService { private config: ProgramConfigFile | null = null; private configPath: string; + private useDatabase: boolean = true; constructor() { this.configPath = CONFIG_PATH; } /** - * Load config from file + * Load config from database or file */ async load(): Promise { + try { + // Try loading from database first + const dbPrograms = await this.loadProgramsFromDb(); + const dbAwards = await this.loadAwardsFromDb(); + + if (dbPrograms.length > 0 || dbAwards.length > 0) { + this.useDatabase = true; + this.config = { + programs: dbPrograms, + awards: dbAwards, + settings: this.getDefaultSettings(), + }; + logger.info({ + programCount: dbPrograms.length, + awardCount: dbAwards.length, + source: 'database' + }, 'Program config loaded from database'); + return; + } + + // Fallback to JSON file + this.useDatabase = false; + await this.loadFromFile(); + } catch (error) { + logger.warn({ error }, 'Database not available, falling back to JSON file'); + this.useDatabase = false; + await this.loadFromFile(); + } + } + + /** + * Load programs from database + */ + private async loadProgramsFromDb(): Promise { + const programs = await prisma.program.findMany({ + where: { isActive: true }, + orderBy: { order: 'asc' }, + }); + return programs.map(p => ({ + id: p.id, + name: p.name, + teamName: p.teamName, + performer: p.performer || undefined, + order: p.order, + remark: p.remark || undefined, + })); + } + + /** + * Load awards from database + */ + private async loadAwardsFromDb(): Promise { + const awards = await prisma.award.findMany({ + where: { isActive: true }, + orderBy: { order: 'asc' }, + }); + return awards.map(a => ({ + id: a.id, + name: a.name, + icon: a.icon, + order: a.order, + remark: a.remark || undefined, + })); + } + + /** + * Load config from JSON file + */ + private async loadFromFile(): Promise { try { if (fs.existsSync(this.configPath)) { const content = fs.readFileSync(this.configPath, 'utf-8'); @@ -60,28 +132,28 @@ class ProgramConfigService { logger.info({ programCount: this.config?.programs.length, awardCount: this.config?.awards?.length || 0, - configPath: this.configPath - }, 'Program config loaded'); - - // Validate: programs.length === awards.length - if (this.config?.programs && this.config?.awards) { - if (this.config.programs.length !== this.config.awards.length) { - logger.warn({ - programCount: this.config.programs.length, - awardCount: this.config.awards.length - }, 'Warning: program count does not match award count'); - } - } + source: 'file' + }, 'Program config loaded from file'); } else { - logger.warn({ configPath: this.configPath }, 'Program config file not found, using defaults'); + logger.warn({ configPath: this.configPath }, 'Config file not found, using defaults'); this.config = this.getDefaults(); } } catch (error) { - logger.error({ error, configPath: this.configPath }, 'Failed to load program config'); + logger.error({ error }, 'Failed to load config from file'); this.config = this.getDefaults(); } } + /** + * Get default settings + */ + private getDefaultSettings(): ProgramSettings { + return { + allowLateCatch: true, + maxVotesPerUser: 7, + }; + } + /** * Get default configuration */ @@ -105,15 +177,12 @@ class ProgramConfigService { { id: 'craftsmanship', name: '匠心独韵奖', icon: '💎', order: 6 }, { id: 'in_sync', name: '同频时代奖', icon: '📻', order: 7 }, ], - settings: { - allowLateCatch: true, - maxVotesPerUser: 7, - }, + settings: this.getDefaultSettings(), }; } /** - * Get all programs (for display/listing) + * Get all programs */ getPrograms(): ProgramConfig[] { return this.config?.programs || this.getDefaults().programs; @@ -134,7 +203,7 @@ class ProgramConfigService { } /** - * Convert config programs to VotingProgram format (with runtime fields) + * Convert config programs to VotingProgram format */ getVotingPrograms(): VotingProgram[] { const programs = this.getPrograms(); @@ -157,21 +226,18 @@ class ProgramConfigService { * Get settings */ getSettings(): ProgramSettings { - return this.config?.settings || this.getDefaults().settings; + return this.config?.settings || this.getDefaultSettings(); } /** - * Update programs and save to file + * Update programs (database or file) */ async updatePrograms(programs: ProgramConfig[]): Promise<{ success: boolean; error?: string }> { try { - if (!this.config) { - this.config = this.getDefaults(); + if (this.useDatabase) { + return await this.updateProgramsInDb(programs); } - this.config.programs = programs; - await this.saveToFile(); - logger.info({ programCount: programs.length }, 'Programs updated'); - return { success: true }; + return await this.updateProgramsInFile(programs); } catch (error) { logger.error({ error }, 'Failed to update programs'); return { success: false, error: (error as Error).message }; @@ -179,17 +245,69 @@ class ProgramConfigService { } /** - * Update awards and save to file + * Update programs in database + */ + private async updateProgramsInDb(programs: ProgramConfig[]): Promise<{ success: boolean; error?: string }> { + // Use transaction to update all programs + await prisma.$transaction(async (tx) => { + // Deactivate all existing programs + await tx.program.updateMany({ + data: { isActive: false } + }); + + // Upsert each program + for (const p of programs) { + await tx.program.upsert({ + where: { id: p.id }, + create: { + id: p.id, + name: p.name, + teamName: p.teamName, + performer: p.performer || '', + order: p.order, + remark: p.remark, + isActive: true, + }, + update: { + name: p.name, + teamName: p.teamName, + performer: p.performer || '', + order: p.order, + remark: p.remark, + isActive: true, + }, + }); + } + }); + + // Reload config + await this.load(); + logger.info({ programCount: programs.length }, 'Programs updated in database'); + return { success: true }; + } + + /** + * Update programs in file + */ + private async updateProgramsInFile(programs: ProgramConfig[]): Promise<{ success: boolean; error?: string }> { + if (!this.config) { + this.config = this.getDefaults(); + } + this.config.programs = programs; + await this.saveToFile(); + logger.info({ programCount: programs.length }, 'Programs updated in file'); + return { success: true }; + } + + /** + * Update awards (database or file) */ async updateAwards(awards: AwardConfig[]): Promise<{ success: boolean; error?: string }> { try { - if (!this.config) { - this.config = this.getDefaults(); + if (this.useDatabase) { + return await this.updateAwardsInDb(awards); } - this.config.awards = awards; - await this.saveToFile(); - logger.info({ awardCount: awards.length }, 'Awards updated'); - return { success: true }; + return await this.updateAwardsInFile(awards); } catch (error) { logger.error({ error }, 'Failed to update awards'); return { success: false, error: (error as Error).message }; @@ -197,7 +315,56 @@ class ProgramConfigService { } /** - * Update settings and save to file + * Update awards in database + */ + private async updateAwardsInDb(awards: AwardConfig[]): Promise<{ success: boolean; error?: string }> { + await prisma.$transaction(async (tx) => { + await tx.award.updateMany({ + data: { isActive: false } + }); + + for (const a of awards) { + await tx.award.upsert({ + where: { id: a.id }, + create: { + id: a.id, + name: a.name, + icon: a.icon, + order: a.order, + remark: a.remark, + isActive: true, + }, + update: { + name: a.name, + icon: a.icon, + order: a.order, + remark: a.remark, + isActive: true, + }, + }); + } + }); + + await this.load(); + logger.info({ awardCount: awards.length }, 'Awards updated in database'); + return { success: true }; + } + + /** + * Update awards in file + */ + private async updateAwardsInFile(awards: AwardConfig[]): Promise<{ success: boolean; error?: string }> { + if (!this.config) { + this.config = this.getDefaults(); + } + this.config.awards = awards; + await this.saveToFile(); + logger.info({ awardCount: awards.length }, 'Awards updated in file'); + return { success: true }; + } + + /** + * Update settings */ async updateSettings(settings: Partial): Promise<{ success: boolean; error?: string }> { try { @@ -205,7 +372,9 @@ class ProgramConfigService { this.config = this.getDefaults(); } this.config.settings = { ...this.config.settings, ...settings }; - await this.saveToFile(); + if (!this.useDatabase) { + await this.saveToFile(); + } logger.info({ settings: this.config.settings }, 'Program settings updated'); return { success: true }; } catch (error) { @@ -223,11 +392,18 @@ class ProgramConfigService { } /** - * Get full config for API response + * Get full config */ getFullConfig(): ProgramConfigFile { return this.config || this.getDefaults(); } + + /** + * Check if using database + */ + isUsingDatabase(): boolean { + return this.useDatabase; + } } export const programConfigService = new ProgramConfigService();