forked from wangziqi/gongxue-base
feat: add CASL authorization and AI configuration
This commit is contained in:
@@ -9,10 +9,97 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.ensureAiConfigTable();
|
||||
await this.backfillOrganizations();
|
||||
await this.normalizeClassDates();
|
||||
}
|
||||
|
||||
private async ensureAiConfigTable(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const tables = await runner.getTables(['ai_config']);
|
||||
const isMySQL = this.dataSource.options.type === 'mysql';
|
||||
|
||||
if (tables.length === 0) {
|
||||
const pkDef = isMySQL
|
||||
? 'id INTEGER PRIMARY KEY AUTO_INCREMENT'
|
||||
: 'id INTEGER PRIMARY KEY AUTOINCREMENT';
|
||||
const boolType = isMySQL ? 'TINYINT(1)' : 'BOOLEAN';
|
||||
const datetimeFn = isMySQL ? 'CURRENT_TIMESTAMP' : 'CURRENT_TIMESTAMP';
|
||||
|
||||
await runner.query(`
|
||||
CREATE TABLE ai_config (
|
||||
${pkDef},
|
||||
singleton_key VARCHAR(20) NOT NULL DEFAULT 'GLOBAL',
|
||||
provider VARCHAR(50) NOT NULL DEFAULT 'OPENAI',
|
||||
base_url VARCHAR(500),
|
||||
encrypted_api_key TEXT,
|
||||
api_key_iv VARCHAR(50),
|
||||
api_key_auth_tag VARCHAR(50),
|
||||
key_last4 VARCHAR(4),
|
||||
default_model VARCHAR(100),
|
||||
enabled ${boolType} DEFAULT 0,
|
||||
timeout_ms INT DEFAULT 30000,
|
||||
verified ${boolType} DEFAULT 0,
|
||||
last_tested_at DATETIME,
|
||||
last_test_latency_ms INT,
|
||||
created_at DATETIME NOT NULL DEFAULT ${datetimeFn},
|
||||
updated_at DATETIME NOT NULL DEFAULT ${datetimeFn}
|
||||
)
|
||||
`);
|
||||
|
||||
if (isMySQL) {
|
||||
try {
|
||||
await runner.query(
|
||||
'CREATE UNIQUE INDEX uq_ai_config_singleton ON ai_config(singleton_key)',
|
||||
);
|
||||
} catch {
|
||||
// Index may already exist; MySQL has no IF NOT EXISTS for indexes
|
||||
}
|
||||
} else {
|
||||
await runner.query(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton ON ai_config(singleton_key)',
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log('已创建 ai_config 表');
|
||||
} else {
|
||||
// Check for missing columns
|
||||
const table = await runner.getTable('ai_config');
|
||||
const columnNames = new Set(table?.columns.map((c) => c.name) ?? []);
|
||||
|
||||
const desiredColumns: Array<{ name: string; def: string }> = [
|
||||
{ name: 'id', def: '' }, // skip — primary key
|
||||
{ name: 'singleton_key', def: "VARCHAR(20) NOT NULL DEFAULT 'GLOBAL'" },
|
||||
{ name: 'provider', def: "VARCHAR(50) NOT NULL DEFAULT 'OPENAI'" },
|
||||
{ name: 'base_url', def: 'VARCHAR(500)' },
|
||||
{ name: 'encrypted_api_key', def: 'TEXT' },
|
||||
{ name: 'api_key_iv', def: 'VARCHAR(50)' },
|
||||
{ name: 'api_key_auth_tag', def: 'VARCHAR(50)' },
|
||||
{ name: 'key_last4', def: 'VARCHAR(4)' },
|
||||
{ name: 'default_model', def: 'VARCHAR(100)' },
|
||||
{ name: 'enabled', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' },
|
||||
{ name: 'timeout_ms', def: 'INT DEFAULT 30000' },
|
||||
{ name: 'verified', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' },
|
||||
{ name: 'last_tested_at', def: 'DATETIME' },
|
||||
{ name: 'last_test_latency_ms', def: 'INT' },
|
||||
{ name: 'created_at', def: 'DATETIME' },
|
||||
{ name: 'updated_at', def: 'DATETIME' },
|
||||
];
|
||||
|
||||
for (const col of desiredColumns) {
|
||||
if (col.def && !columnNames.has(col.name)) {
|
||||
await runner.query(`ALTER TABLE ai_config ADD COLUMN ${col.name} ${col.def}`);
|
||||
this.logger.log(`已为 ai_config 表添加列: ${col.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async backfillOrganizations(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
|
||||
178
apps/server/src/database/database-migrations.spec.ts
Normal file
178
apps/server/src/database/database-migrations.spec.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { TestingModule, Test } from '@nestjs/testing';
|
||||
import { DatabaseMigrationsService } from './database-migrations.service';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
|
||||
interface MockColumn {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface MockTable {
|
||||
name: string;
|
||||
columns: MockColumn[];
|
||||
}
|
||||
|
||||
function mockRunner(overrides: {
|
||||
getTables?: MockTable[];
|
||||
getTable?: MockTable;
|
||||
queryError?: Error;
|
||||
} = {}) {
|
||||
const release = jest.fn();
|
||||
const connect = jest.fn();
|
||||
const query = jest.fn();
|
||||
const getTables = jest.fn().mockResolvedValue(overrides.getTables ?? []);
|
||||
const getTable = jest.fn().mockResolvedValue(
|
||||
overrides.getTable ?? { name: 'ai_config', columns: [] },
|
||||
);
|
||||
|
||||
if (overrides.queryError) {
|
||||
query.mockRejectedValue(overrides.queryError);
|
||||
}
|
||||
|
||||
return { release, connect, query, getTables, getTable };
|
||||
}
|
||||
|
||||
function createDataSource(runner: ReturnType<typeof mockRunner>) {
|
||||
return {
|
||||
options: { type: 'better-sqlite3' },
|
||||
createQueryRunner: jest.fn().mockReturnValue(runner),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
// Type to reach the private ensureAiConfigTable for testing
|
||||
interface MigrationsPrivate {
|
||||
ensureAiConfigTable(): Promise<void>;
|
||||
backfillOrganizations(): Promise<void>;
|
||||
normalizeClassDates(): Promise<void>;
|
||||
}
|
||||
|
||||
describe('DatabaseMigrationsService — ensureAiConfigTable', () => {
|
||||
let service: MigrationsPrivate & DatabaseMigrationsService;
|
||||
|
||||
async function bootstrap(runner: ReturnType<typeof mockRunner>) {
|
||||
const dataSource = createDataSource(runner);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DatabaseMigrationsService,
|
||||
{ provide: getDataSourceToken(), useValue: dataSource },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get(
|
||||
DatabaseMigrationsService,
|
||||
);
|
||||
}
|
||||
|
||||
it('creates table + index when ai_config does not exist', async () => {
|
||||
const runner = mockRunner({ getTables: [] });
|
||||
await bootstrap(runner);
|
||||
await service.ensureAiConfigTable();
|
||||
|
||||
expect(runner.connect).toHaveBeenCalled();
|
||||
expect(runner.query).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE ai_config'));
|
||||
expect(runner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton'),
|
||||
);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips ALTER when table exists with all columns', async () => {
|
||||
const allColumns: MockColumn[] = [
|
||||
{ name: 'id' },
|
||||
{ name: 'singleton_key' },
|
||||
{ name: 'provider' },
|
||||
{ name: 'base_url' },
|
||||
{ name: 'encrypted_api_key' },
|
||||
{ name: 'api_key_iv' },
|
||||
{ name: 'api_key_auth_tag' },
|
||||
{ name: 'key_last4' },
|
||||
{ name: 'default_model' },
|
||||
{ name: 'enabled' },
|
||||
{ name: 'timeout_ms' },
|
||||
{ name: 'verified' },
|
||||
{ name: 'last_tested_at' },
|
||||
{ name: 'last_test_latency_ms' },
|
||||
{ name: 'created_at' },
|
||||
{ name: 'updated_at' },
|
||||
];
|
||||
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'ai_config', columns: allColumns }],
|
||||
getTable: { name: 'ai_config', columns: allColumns },
|
||||
});
|
||||
await bootstrap(runner);
|
||||
await service.ensureAiConfigTable();
|
||||
|
||||
expect(runner.connect).toHaveBeenCalled();
|
||||
// Should NOT issue any ALTER TABLE
|
||||
const alterCalls = (runner.query as jest.Mock).mock.calls.filter(
|
||||
(c: unknown[]) => typeof c[0] === 'string' && (c[0]).includes('ALTER TABLE'),
|
||||
);
|
||||
expect(alterCalls).toHaveLength(0);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds missing column via ALTER TABLE', async () => {
|
||||
// Table has most columns but is missing last_test_latency_ms
|
||||
const missingOne: MockColumn[] = [
|
||||
{ name: 'id' },
|
||||
{ name: 'singleton_key' },
|
||||
{ name: 'provider' },
|
||||
{ name: 'base_url' },
|
||||
{ name: 'encrypted_api_key' },
|
||||
{ name: 'api_key_iv' },
|
||||
{ name: 'api_key_auth_tag' },
|
||||
{ name: 'key_last4' },
|
||||
{ name: 'default_model' },
|
||||
{ name: 'enabled' },
|
||||
{ name: 'timeout_ms' },
|
||||
{ name: 'verified' },
|
||||
{ name: 'last_tested_at' },
|
||||
// last_test_latency_ms missing
|
||||
{ name: 'created_at' },
|
||||
{ name: 'updated_at' },
|
||||
];
|
||||
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'ai_config', columns: missingOne }],
|
||||
getTable: { name: 'ai_config', columns: missingOne },
|
||||
});
|
||||
await bootstrap(runner);
|
||||
await service.ensureAiConfigTable();
|
||||
|
||||
expect(runner.connect).toHaveBeenCalled();
|
||||
expect(runner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ALTER TABLE ai_config ADD COLUMN last_test_latency_ms INT'),
|
||||
);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases runner even when query throws', async () => {
|
||||
const runner = mockRunner({ getTables: [], queryError: new Error('BOOM') });
|
||||
await bootstrap(runner);
|
||||
|
||||
await expect(service.ensureAiConfigTable()).rejects.toThrow('BOOM');
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DatabaseMigrationsService — bootstrap failure handling', () => {
|
||||
it('fails application bootstrap when the required ai_config migration fails', async () => {
|
||||
const runner = mockRunner();
|
||||
const dataSource = createDataSource(runner);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DatabaseMigrationsService,
|
||||
{ provide: getDataSourceToken(), useValue: dataSource },
|
||||
],
|
||||
}).compile();
|
||||
const service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & MigrationsPrivate;
|
||||
|
||||
jest.spyOn(service, 'ensureAiConfigTable').mockRejectedValue(new Error('migration failed'));
|
||||
const backfill = jest.spyOn(service, 'backfillOrganizations').mockResolvedValue();
|
||||
const normalize = jest.spyOn(service, 'normalizeClassDates').mockResolvedValue();
|
||||
|
||||
await expect(service.onApplicationBootstrap()).rejects.toThrow('migration failed');
|
||||
expect(backfill).not.toHaveBeenCalled();
|
||||
expect(normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user