feat: add CASL authorization and AI configuration

This commit is contained in:
2026-07-11 14:25:34 +08:00
parent 8f0991a51f
commit 1e1c476bc3
59 changed files with 7733 additions and 120 deletions

View File

@@ -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();

View 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();
});
});