Initialize backendized SurClaw report system

- Add React/Vite frontend for login, dashboard, reports, templates, users, settings, AI, speech, and media workflows.

- Add NestJS/Prisma/PostgreSQL backend with auth, dashboard stats, reports, templates, users, departments, settings, files, AI, speech, audit logs, and HTML sanitization.

- Add Prisma schema, migrations, seed data, persistent app sessions, Docker/Nginx deployment files, and upload volume configuration.

- Add Vitest, Playwright, backend integration tests, and project documentation for requirements, design, permissions, API contracts, testing, deployment, security, and progress.

- Configure production local fallback switch and remove unused Gemini direct dependency/env wiring.
This commit is contained in:
2026-05-02 01:37:20 +08:00
commit 014aca8619
162 changed files with 27116 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
import { Body, Controller, Get, Patch, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from '../auth/auth.service.js';
import { getSessionUser } from '../auth/session-user.js';
import { SettingsService } from './settings.service.js';
@Controller('settings')
export class SettingsController {
constructor(
private readonly authService: AuthService,
private readonly settingsService: SettingsService,
) {}
@Get('system')
async getSystem(@Req() request: Request) {
const actor = await getSessionUser(request, this.authService);
return { data: { settings: await this.settingsService.getSystemSettings(actor) } };
}
@Patch('system')
async updateSystem(@Req() request: Request, @Body() body: unknown) {
const actor = await getSessionUser(request, this.authService);
return { data: { settings: await this.settingsService.updateSystemSettings(actor, body) } };
}
@Post('system/reset')
async resetSystem(@Req() request: Request) {
const actor = await getSessionUser(request, this.authService);
return { data: { settings: await this.settingsService.resetSystemSettings(actor) } };
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module.js';
import { PrismaModule } from '../prisma/prisma.module.js';
import { SettingsController } from './settings.controller.js';
import { SettingsService } from './settings.service.js';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [SettingsController],
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { systemSettingsSchema } from './settings.schemas';
describe('settings schemas', () => {
it('normalizes partial system settings with safe defaults', () => {
const result = systemSettingsSchema.parse({
frameCount: 2,
framePositions: [75, 25],
activeAiProvider: 'custom',
aiProviders: {
custom: {
endpoint: 'https://example.test/v1',
modelName: 'demo-model',
},
},
});
expect(result.frameCount).toBe(2);
expect(result.framePositions).toEqual([75, 25]);
expect(result.frameMode).toBe('keep');
expect(result.aiProviders.custom.apiKey).toBe('');
});
it('rejects invalid frame positions', () => {
expect(() => systemSettingsSchema.parse({
frameCount: 1,
framePositions: [120],
activeAiProvider: 'kimi',
aiProviders: {},
})).toThrow();
});
});

View File

@@ -0,0 +1,28 @@
import { z } from 'zod';
export const aiProviderSchema = z.object({
endpoint: z.string().default(''),
apiKey: z.string().default(''),
modelName: z.string().default(''),
}).passthrough();
export const xfSpeechConfigSchema = z.object({
appId: z.string().default(''),
apiKey: z.string().default(''),
apiSecret: z.string().default(''),
}).passthrough();
export const systemSettingsSchema = z.object({
frameCount: z.number().int().min(1).max(100).default(12),
framePositions: z.array(z.number().min(0).max(100)).default([]),
defaultTemplate: z.string().optional(),
frameMode: z.enum(['uniform', 'keep']).default('keep'),
autoInsertFrames: z.boolean().optional(),
autoInsertFrameIndices: z.array(z.number().int().min(0)).optional(),
autoInsertDelay: z.number().min(0).optional(),
activeAiProvider: z.string().default('kimi'),
aiProviders: z.record(z.string(), aiProviderSchema).default({}),
xfSpeechConfig: xfSpeechConfigSchema.optional(),
}).passthrough();
export type SystemSettingsInput = z.infer<typeof systemSettingsSchema>;

View File

@@ -0,0 +1,212 @@
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { AuditService } from '../audit/audit.service.js';
import type { SafeUser } from '../auth/auth.types.js';
import { isSuper } from '../permissions/permissions.policy.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { systemSettingsSchema, type SystemSettingsInput } from './settings.schemas.js';
const DEFAULT_AI_PROVIDERS = {
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: '', modelName: 'moonshot-v1-32k-vision-preview' },
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' },
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' },
custom: { endpoint: '', apiKey: '', modelName: '' },
};
const DEFAULT_SETTINGS: SystemSettingsInput = {
frameCount: 12,
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85, 96.3, 98.6],
defaultTemplate: '',
frameMode: 'keep',
activeAiProvider: 'kimi',
aiProviders: DEFAULT_AI_PROVIDERS,
autoInsertFrames: true,
autoInsertDelay: 1,
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10],
xfSpeechConfig: { appId: '', apiKey: '', apiSecret: '' },
};
@Injectable()
export class SettingsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit?: AuditService,
) {}
async getSystemSettings(actor: SafeUser, options: { includeSecrets?: boolean } = {}) {
const globalSettings = await this.getSettingValue(actor.tenantId, 'global', 'systemSettings');
const userDefaultTemplate = await this.getSettingValue(
actor.tenantId,
this.userScope(actor.id),
'defaultTemplate',
);
const merged = this.normalize({
...DEFAULT_SETTINGS,
...(this.isObject(globalSettings) ? globalSettings : {}),
});
if (typeof userDefaultTemplate === 'string') {
merged.defaultTemplate = userDefaultTemplate;
}
return options.includeSecrets || isSuper(this.actorToPolicy(actor))
? merged
: this.redactSecrets(merged);
}
async updateSystemSettings(actor: SafeUser, rawInput: unknown) {
const result = systemSettingsSchema.safeParse(rawInput);
if (!result.success) {
throw new BadRequestException(result.error.issues.map((issue) => issue.message).join(''));
}
const input = this.normalize(result.data);
if (isSuper(this.actorToPolicy(actor))) {
await this.setSettingValue(actor.tenantId, 'global', 'systemSettings', this.toJson(input));
if (input.defaultTemplate !== undefined) {
await this.setSettingValue(
actor.tenantId,
this.userScope(actor.id),
'defaultTemplate',
input.defaultTemplate || '',
);
}
await this.audit?.record({
actor,
action: 'settings.system.update',
targetType: 'SystemSetting',
targetId: 'systemSettings',
metadata: { scope: 'global', defaultTemplate: input.defaultTemplate },
});
return this.getSystemSettings(actor);
}
if (input.defaultTemplate === undefined) {
throw new ForbiddenException('只能修改个人默认模板');
}
await this.setSettingValue(
actor.tenantId,
this.userScope(actor.id),
'defaultTemplate',
input.defaultTemplate || '',
);
await this.audit?.record({
actor,
action: 'settings.default_template.update',
targetType: 'SystemSetting',
targetId: 'defaultTemplate',
metadata: { defaultTemplate: input.defaultTemplate },
});
return this.getSystemSettings(actor);
}
async resetSystemSettings(actor: SafeUser) {
if (!isSuper(this.actorToPolicy(actor))) {
throw new ForbiddenException('只有超级管理员可以重置系统设置');
}
await this.setSettingValue(actor.tenantId, 'global', 'systemSettings', this.toJson(DEFAULT_SETTINGS));
await this.audit?.record({
actor,
action: 'settings.system.reset',
targetType: 'SystemSetting',
targetId: 'systemSettings',
metadata: { scope: 'global' },
});
return this.getSystemSettings(actor);
}
private normalize(input: SystemSettingsInput): SystemSettingsInput {
const aiProviders = {
...DEFAULT_AI_PROVIDERS,
...(input.aiProviders || {}),
};
const framePositions = [...(input.framePositions || DEFAULT_SETTINGS.framePositions)]
.map((value) => Math.round(value * 10) / 10)
.sort((a, b) => a - b);
return {
...DEFAULT_SETTINGS,
...input,
framePositions,
frameCount: framePositions.length || input.frameCount || DEFAULT_SETTINGS.frameCount,
frameMode: input.frameMode || 'keep',
activeAiProvider: input.activeAiProvider || 'kimi',
aiProviders,
xfSpeechConfig: input.xfSpeechConfig || DEFAULT_SETTINGS.xfSpeechConfig,
};
}
private redactSecrets(settings: SystemSettingsInput): SystemSettingsInput {
const aiProviders = Object.fromEntries(
Object.entries(settings.aiProviders || {}).map(([key, provider]) => [
key,
{ ...provider, apiKey: '' },
]),
);
return {
...settings,
aiProviders,
xfSpeechConfig: settings.xfSpeechConfig
? { ...settings.xfSpeechConfig, apiKey: '', apiSecret: '' }
: settings.xfSpeechConfig,
};
}
private async getSettingValue(tenantId: string, scope: string, key: string) {
const setting = await this.prisma.systemSetting.findFirst({
where: { tenantId, scope, departmentId: null, key },
});
return setting?.value;
}
private async setSettingValue(
tenantId: string,
scope: string,
key: string,
value: Prisma.InputJsonValue,
) {
const existing = await this.prisma.systemSetting.findFirst({
where: { tenantId, scope, departmentId: null, key },
});
if (existing) {
await this.prisma.systemSetting.update({
where: { id: existing.id },
data: { value },
});
return;
}
await this.prisma.systemSetting.create({
data: {
tenantId,
scope,
key,
value,
},
});
}
private toJson(value: unknown): Prisma.InputJsonValue {
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}
private isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
private userScope(userId: string) {
return `user:${userId}`;
}
private actorToPolicy(actor: SafeUser) {
return {
id: actor.id,
tenantId: actor.tenantId,
departmentId: actor.departmentId,
role: actor.role,
};
}
}