feat: 接入真实登录与成员权限

- auth/members/messages: 新增超级管理员登录、成员权限和密码消息流程
- agent-config/agent: 支持服务端保存 Agent 配置并取消未配置 mock 成功结果
- projects/deploy-runs/settings: 按当前用户权限保护真实接口
- prisma: 新增用户、项目权限和平台消息表结构
This commit is contained in:
湛兮
2026-06-12 00:38:23 +08:00
parent cf1bebf625
commit bc3aa31289
83 changed files with 3844 additions and 378 deletions
@@ -0,0 +1,54 @@
import { Body, Controller, Get, Post, Put, UseGuards } from '@nestjs/common';
import { ApiBody, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AdminOnlyMessage } from '../auth/admin-only-message.decorator';
import { AuthGuard } from '../auth/auth.guard';
import { AuthenticatedUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/current-user.decorator';
import { PasswordChangeGuard } from '../auth/password-change.guard';
import { SuperAdminGuard } from '../auth/super-admin.guard';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import {
SaveAgentConfigDto,
SaveAgentConfigInput,
saveAgentConfigSchema,
} from './agent-config.dto';
import { AgentConfigService } from './agent-config.service';
import {
AgentConfigSummary,
AgentConnectionTestResult,
} from './agent-config.types';
@ApiTags('Agent 配置')
@Controller('agent/config')
@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard)
@AdminOnlyMessage('无权限访问 Agent 配置')
export class AgentConfigController {
constructor(private readonly agentConfigService: AgentConfigService) {}
@Get()
@ApiOkResponse({ description: '获取 Agent 配置,密钥只返回脱敏内容。' })
async getConfig(
@CurrentUser() user: AuthenticatedUser,
): Promise<AgentConfigSummary> {
return this.agentConfigService.getConfig(user);
}
@Put()
@ApiBody({ type: SaveAgentConfigDto })
@ApiOkResponse({ description: '保存 Agent 配置。' })
async saveConfig(
@CurrentUser() user: AuthenticatedUser,
@Body(new ZodValidationPipe(saveAgentConfigSchema))
body: SaveAgentConfigInput,
): Promise<AgentConfigSummary> {
return this.agentConfigService.saveConfig(user, body);
}
@Post('test')
@ApiOkResponse({ description: '测试已保存的 Agent 配置连接。' })
async testConnection(
@CurrentUser() user: AuthenticatedUser,
): Promise<AgentConnectionTestResult> {
return this.agentConfigService.testConnection(user);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { z } from 'zod';
export const saveAgentConfigSchema = z.object({
key: z
.string()
.trim()
.max(500)
.optional()
.or(z.literal('').transform(() => undefined)),
baseURL: z.string().trim().url().max(300),
model: z.string().trim().min(1).max(120),
});
export type SaveAgentConfigInput = z.infer<typeof saveAgentConfigSchema>;
export class SaveAgentConfigDto {
@ApiPropertyOptional({
description: 'Agent 密钥;留空表示保留已保存密钥',
example: 'sk-xxxx',
})
key?: string;
@ApiProperty({
description: 'Agent 服务基础地址,例如 OpenAI 兼容接口的 /v1 地址',
example: 'https://api.example.com/v1',
})
baseURL!: string;
@ApiProperty({ description: 'Agent 使用的模型名称', example: 'gpt-4.1' })
model!: string;
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { AgentConfigController } from './agent-config.controller';
import { AgentConfigRepository } from './agent-config.repository';
import { AgentConfigService } from './agent-config.service';
import { SecretCryptoService } from './secret-crypto.service';
@Module({
imports: [AuditModule, AuthModule],
controllers: [AgentConfigController],
providers: [AgentConfigRepository, AgentConfigService, SecretCryptoService],
exports: [AgentConfigRepository, AgentConfigService, SecretCryptoService],
})
export class AgentConfigModule {}
+148
View File
@@ -0,0 +1,148 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma, SecretProvider } from '@prisma/client';
import { AppError } from '../common/errors/app-error';
import { EnvConfig } from '../config/env.schema';
import { PrismaService } from '../prisma/prisma.service';
import { SaveAgentConfigInput } from './agent-config.dto';
import { AgentRuntimeConfig } from './agent-config.types';
import { SecretCryptoService } from './secret-crypto.service';
type StoredAgentConfig = Partial<AgentRuntimeConfig> & {
updatedAt?: string;
};
const AGENT_SECRET_SCOPE_TYPE = 'system';
const AGENT_SECRET_SCOPE_ID = 'agent-config';
const AGENT_SECRET_NAMES = {
key: 'api_key',
baseURL: 'base_url',
model: 'model',
} as const;
@Injectable()
export class AgentConfigRepository {
private memoryConfig?: StoredAgentConfig;
constructor(
private readonly config: ConfigService<EnvConfig, true>,
private readonly prisma: PrismaService,
private readonly secretCrypto: SecretCryptoService,
) {}
async getRuntimeConfig(): Promise<StoredAgentConfig> {
const storedConfig = this.useDatabase()
? await this.readDatabaseConfig()
: this.memoryConfig;
return {
key: storedConfig?.key ?? this.config.get('LLM_API_KEY', { infer: true }),
baseURL:
storedConfig?.baseURL ?? this.config.get('LLM_BASE_URL', { infer: true }),
model: storedConfig?.model ?? this.config.get('LLM_MODEL', { infer: true }),
updatedAt: storedConfig?.updatedAt,
};
}
async saveConfig(input: SaveAgentConfigInput): Promise<StoredAgentConfig> {
const currentConfig = await this.getRuntimeConfig();
const nextConfig: AgentRuntimeConfig = {
key: input.key ?? currentConfig.key ?? '',
baseURL: input.baseURL,
model: input.model,
};
if (!nextConfig.key) {
throw new AppError('VALIDATION_FAILED', '请填写 Agent 密钥', 400);
}
if (!this.useDatabase()) {
this.memoryConfig = {
...nextConfig,
updatedAt: new Date().toISOString(),
};
return this.memoryConfig;
}
await this.prisma.$transaction(async (tx) => {
await this.upsertSecret(tx, AGENT_SECRET_NAMES.key, nextConfig.key);
await this.upsertSecret(
tx,
AGENT_SECRET_NAMES.baseURL,
nextConfig.baseURL,
);
await this.upsertSecret(tx, AGENT_SECRET_NAMES.model, nextConfig.model);
});
return this.getRuntimeConfig();
}
private async readDatabaseConfig(): Promise<StoredAgentConfig> {
const secrets = await this.prisma.secret.findMany({
where: {
provider: SecretProvider.LLM,
scopeType: AGENT_SECRET_SCOPE_TYPE,
scopeId: AGENT_SECRET_SCOPE_ID,
},
});
const secretByName = new Map(
secrets.map((secret) => [secret.name, secret] as const),
);
const updatedAt = secrets
.map((secret) => secret.updatedAt)
.sort((left, right) => right.getTime() - left.getTime())[0]
?.toISOString();
return {
key: this.decryptSecret(secretByName.get(AGENT_SECRET_NAMES.key)),
baseURL: this.decryptSecret(secretByName.get(AGENT_SECRET_NAMES.baseURL)),
model: this.decryptSecret(secretByName.get(AGENT_SECRET_NAMES.model)),
updatedAt,
};
}
private decryptSecret(
secret: { encryptedValue: string } | undefined,
): string | undefined {
return secret ? this.secretCrypto.decrypt(secret.encryptedValue) : undefined;
}
private async upsertSecret(
tx: Prisma.TransactionClient,
name: string,
value: string,
): Promise<void> {
await tx.secret.upsert({
where: {
provider_scopeType_scopeId_name: {
provider: SecretProvider.LLM,
scopeType: AGENT_SECRET_SCOPE_TYPE,
scopeId: AGENT_SECRET_SCOPE_ID,
name,
},
},
create: {
provider: SecretProvider.LLM,
scopeType: AGENT_SECRET_SCOPE_TYPE,
scopeId: AGENT_SECRET_SCOPE_ID,
name,
encryptedValue: this.secretCrypto.encrypt(value),
rotatedAt: new Date(),
},
update: {
encryptedValue: this.secretCrypto.encrypt(value),
version: {
increment: 1,
},
rotatedAt: new Date(),
},
});
}
private useDatabase(): boolean {
return (
this.config.get('USE_DATABASE_READS', { infer: true }) &&
Boolean(this.config.get('DATABASE_URL', { infer: true }))
);
}
}
@@ -0,0 +1,96 @@
import { AuditService } from '../audit/audit.service';
import { AuthenticatedUser } from '../auth/auth.types';
import { AppError } from '../common/errors/app-error';
import { AgentConfigRepository } from './agent-config.repository';
import { AgentConfigService } from './agent-config.service';
describe('AgentConfigService', () => {
const admin: AuthenticatedUser = {
id: 'user_admin',
account: 'zhanxi',
displayName: '超级管理员',
role: 'super_admin',
status: 'active',
mustChangePassword: false,
passwordVersion: 1,
};
const member: AuthenticatedUser = {
id: 'user_member',
account: 'member01',
displayName: '成员一',
role: 'member',
status: 'active',
mustChangePassword: false,
passwordVersion: 1,
};
function createService() {
let config = {
key: 'sk-test-1234567890abcd',
baseURL: 'https://api.example.com/v1',
model: 'gpt-test',
updatedAt: '2026-06-11T00:00:00.000Z',
};
const repository = {
getRuntimeConfig: jest.fn(() => Promise.resolve(config)),
saveConfig: jest.fn((input) => {
config = {
...config,
key: input.key ?? config.key,
baseURL: input.baseURL,
model: input.model,
};
return Promise.resolve(config);
}),
} satisfies Pick<AgentConfigRepository, 'getRuntimeConfig' | 'saveConfig'>;
const audit = {
record: jest.fn(() => Promise.resolve()),
} satisfies Pick<AuditService, 'record'>;
return {
audit,
repository,
service: new AgentConfigService(
repository as unknown as AgentConfigRepository,
audit as unknown as AuditService,
),
};
}
it('拒绝普通成员访问 Agent 配置', async () => {
const { service } = createService();
await expect(service.getConfig(member)).rejects.toBeInstanceOf(AppError);
});
it('返回配置时只展示脱敏密钥', async () => {
const { service } = createService();
const result = await service.getConfig(admin);
expect(result.hasKey).toBe(true);
expect(result.maskedKey).toBe('sk-****abcd');
expect(JSON.stringify(result)).not.toContain('1234567890');
});
it('保存配置时审计变更字段但不记录完整密钥', async () => {
const { audit, service } = createService();
await service.saveConfig(admin, {
key: 'sk-new-abcdef123456',
baseURL: 'https://api.example.com/v1',
model: 'gpt-new',
});
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
after: expect.objectContaining({
changedFields: expect.arrayContaining(['key', 'model']),
}),
}),
);
expect(JSON.stringify(audit.record.mock.calls)).not.toContain(
'sk-new-abcdef123456',
);
});
});
+174
View File
@@ -0,0 +1,174 @@
import { Injectable } from '@nestjs/common';
import { AuditService } from '../audit/audit.service';
import { AuthenticatedUser } from '../auth/auth.types';
import { AppError } from '../common/errors/app-error';
import { redactSensitive } from '../common/security/redact-sensitive';
import { SaveAgentConfigInput } from './agent-config.dto';
import { AgentConfigRepository } from './agent-config.repository';
import {
AgentConfigSummary,
AgentConnectionTestResult,
AgentRuntimeConfig,
} from './agent-config.types';
const AGENT_CONFIG_WARNING =
'该配置影响系统 Agent 能力,请谨慎修改。密钥仅在服务端加密保存,前端不会返回完整明文。';
@Injectable()
export class AgentConfigService {
constructor(
private readonly agentConfigRepository: AgentConfigRepository,
private readonly auditService: AuditService,
) {}
async getConfig(user: AuthenticatedUser): Promise<AgentConfigSummary> {
this.assertSuperAdmin(user);
return this.toSummary(await this.agentConfigRepository.getRuntimeConfig());
}
async saveConfig(
user: AuthenticatedUser,
input: SaveAgentConfigInput,
): Promise<AgentConfigSummary> {
this.assertSuperAdmin(user);
const before = await this.agentConfigRepository.getRuntimeConfig();
const savedConfig = await this.agentConfigRepository.saveConfig(input);
await this.auditService.record({
action: 'AGENT_CONFIG_UPDATED',
resourceType: 'agent_config',
actorId: user.id,
actorName: user.account,
before: {
baseURL: before.baseURL,
model: before.model,
hasKey: Boolean(before.key),
},
after: {
baseURL: savedConfig.baseURL,
model: savedConfig.model,
hasKey: Boolean(savedConfig.key),
changedFields: this.changedFields(before, savedConfig, Boolean(input.key)),
},
});
return this.toSummary(savedConfig);
}
async testConnection(
user: AuthenticatedUser,
): Promise<AgentConnectionTestResult> {
this.assertSuperAdmin(user);
const config = await this.requireRuntimeConfig();
try {
const response = await fetch(`${this.trimSlash(config.baseURL)}/models`, {
method: 'GET',
headers: {
authorization: `Bearer ${config.key}`,
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return {
success: true,
message: 'Agent 连接测试成功',
checkedAt: new Date().toISOString(),
};
} catch (error) {
const message =
error instanceof Error ? error.message : 'Agent 连接测试失败';
const safeMessage = redactSensitive({ message }).message;
throw new AppError(
'INTEGRATION_REQUEST_FAILED',
`Agent 连接测试失败:${safeMessage}`,
400,
);
}
}
async resolveRuntimeConfig(): Promise<AgentRuntimeConfig | null> {
const config = await this.agentConfigRepository.getRuntimeConfig();
if (!config.key || !config.baseURL || !config.model) {
return null;
}
return {
key: config.key,
baseURL: config.baseURL,
model: config.model,
};
}
async requireRuntimeConfig(): Promise<AgentRuntimeConfig> {
const config = await this.resolveRuntimeConfig();
if (!config) {
throw new AppError(
'CONFIG_MISSING',
'Agent 配置不完整,请联系超级管理员配置 key、baseURL 和 model',
500,
);
}
return config;
}
private assertSuperAdmin(user: AuthenticatedUser): void {
if (user.role !== 'super_admin') {
throw new AppError('FORBIDDEN', '无权限访问 Agent 配置', 403);
}
}
private toSummary(
config: Partial<AgentRuntimeConfig> & { updatedAt?: string },
): AgentConfigSummary {
return {
baseURL: config.baseURL ?? '',
model: config.model ?? '',
hasKey: Boolean(config.key),
maskedKey: config.key ? this.maskKey(config.key) : undefined,
warning: AGENT_CONFIG_WARNING,
updatedAt: config.updatedAt,
};
}
private maskKey(key: string): string {
if (key.length <= 8) {
return `${key.slice(0, 2)}****${key.slice(-2)}`;
}
return `${key.slice(0, 3)}****${key.slice(-4)}`;
}
private changedFields(
before: Partial<AgentRuntimeConfig>,
after: Partial<AgentRuntimeConfig>,
keyProvided: boolean,
): string[] {
const fields: string[] = [];
if (keyProvided && before.key !== after.key) {
fields.push('key');
}
if (before.baseURL !== after.baseURL) {
fields.push('baseURL');
}
if (before.model !== after.model) {
fields.push('model');
}
return fields;
}
private trimSlash(value: string): string {
return value.replace(/\/+$/, '');
}
}
+20
View File
@@ -0,0 +1,20 @@
export type AgentConfigSummary = {
baseURL: string;
model: string;
hasKey: boolean;
maskedKey?: string;
warning: string;
updatedAt?: string;
};
export type AgentRuntimeConfig = {
key: string;
baseURL: string;
model: string;
};
export type AgentConnectionTestResult = {
success: boolean;
message: string;
checkedAt: string;
};
+72
View File
@@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
import { AppError } from '../common/errors/app-error';
import { EnvConfig } from '../config/env.schema';
const ENCRYPTION_PREFIX = 'enc:v1';
@Injectable()
export class SecretCryptoService {
constructor(private readonly config: ConfigService<EnvConfig, true>) {}
encrypt(value: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', this.resolveKey(), iv);
const encrypted = Buffer.concat([
cipher.update(value, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return [
ENCRYPTION_PREFIX,
iv.toString('base64url'),
authTag.toString('base64url'),
encrypted.toString('base64url'),
].join(':');
}
decrypt(value: string): string {
if (!value.startsWith(`${ENCRYPTION_PREFIX}:`)) {
return value;
}
const [, , encodedIv, encodedAuthTag, encodedEncrypted] = value.split(':');
if (!encodedIv || !encodedAuthTag || !encodedEncrypted) {
throw new AppError('CONFIG_MISSING', '密钥密文格式不正确', 500);
}
const decipher = createDecipheriv(
'aes-256-gcm',
this.resolveKey(),
Buffer.from(encodedIv, 'base64url'),
);
decipher.setAuthTag(Buffer.from(encodedAuthTag, 'base64url'));
return Buffer.concat([
decipher.update(Buffer.from(encodedEncrypted, 'base64url')),
decipher.final(),
]).toString('utf8');
}
private resolveKey(): Buffer {
const secret =
this.config.get('SECRET_ENCRYPTION_KEY', { infer: true }) ??
this.config.get('AUTH_TOKEN_SECRET', { infer: true }) ??
(this.config.get('NODE_ENV', { infer: true }) === 'production'
? undefined
: 'devops-platform-local-secret-key');
if (!secret) {
throw new AppError(
'CONFIG_MISSING',
'缺少 SECRET_ENCRYPTION_KEY,不能保存 Agent 密钥',
500,
);
}
return createHash('sha256').update(secret).digest();
}
}
+11 -5
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import {
ApiBody,
ApiCreatedResponse,
@@ -6,6 +6,10 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { AuthGuard } from '../auth/auth.guard';
import { AuthenticatedUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/current-user.decorator';
import { PasswordChangeGuard } from '../auth/password-change.guard';
import { AgentInvocation } from './agent.types';
import { AgentService } from './agent.service';
import {
@@ -14,24 +18,26 @@ import {
createAgentInvocationSchema,
} from './create-agent-invocation.dto';
@ApiTags('agent')
@ApiTags('Agent 调用')
@Controller('agent/invocations')
@UseGuards(AuthGuard, PasswordChangeGuard)
export class AgentController {
constructor(private readonly agentService: AgentService) {}
@Get()
@ApiOkResponse({ description: 'Agent invocation audit list.' })
@ApiOkResponse({ description: '查询 Agent 调用记录。' })
listInvocations(): AgentInvocation[] {
return this.agentService.listInvocations();
}
@Post()
@ApiBody({ type: CreateAgentInvocationDto })
@ApiCreatedResponse({ description: 'Create a DevOps-scoped agent invocation.' })
@ApiCreatedResponse({ description: '创建 DevOps 工作流范围内的 Agent 调用。' })
async createInvocation(
@CurrentUser() user: AuthenticatedUser,
@Body(new ZodValidationPipe(createAgentInvocationSchema))
body: CreateAgentInvocationInput,
): Promise<AgentInvocation> {
return this.agentService.createInvocation(body);
return this.agentService.createInvocation(body, user);
}
}
+3 -1
View File
@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { AgentConfigModule } from '../agent-config/agent-config.module';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { AgentController } from './agent.controller';
import { AgentService } from './agent.service';
import { LlmClient } from './llm.client';
@Module({
imports: [AuditModule],
imports: [AgentConfigModule, AuditModule, AuthModule],
controllers: [AgentController],
providers: [AgentService, LlmClient],
exports: [AgentService, LlmClient],
+56 -22
View File
@@ -1,4 +1,6 @@
import { AuditService } from '../audit/audit.service';
import { AuthService } from '../auth/auth.service';
import { AuthenticatedUser } from '../auth/auth.types';
import { AppError } from '../common/errors/app-error';
import { AgentService } from './agent.service';
import { CreateAgentInvocationInput } from './create-agent-invocation.dto';
@@ -8,6 +10,16 @@ import { LlmClient } from './llm.client';
* 锁定 Agent 入口只服务 DevOps 场景,避免后续误扩成泛聊天接口。
*/
describe('AgentService', () => {
const operator: AuthenticatedUser = {
id: 'user_admin',
account: 'zhanxi',
displayName: '超级管理员',
role: 'super_admin',
status: 'active',
mustChangePassword: false,
passwordVersion: 1,
};
function createService(options?: {
llmConfigured?: boolean;
llmText?: string;
@@ -17,9 +29,14 @@ describe('AgentService', () => {
} satisfies Pick<AuditService, 'record'>;
const llm = {
healthSummary: jest.fn(() =>
options?.llmConfigured
? { status: 'ok' as const }
: { status: 'not_configured' as const, message: 'Missing LLM_API_KEY' },
Promise.resolve(
options?.llmConfigured
? { status: 'ok' as const }
: {
status: 'not_configured' as const,
message: 'Agent 配置不完整',
},
),
),
complete: jest.fn(() =>
Promise.resolve({
@@ -28,34 +45,45 @@ describe('AgentService', () => {
}),
),
} satisfies Pick<LlmClient, 'healthSummary' | 'complete'>;
const auth = {
requireProjectPermission: jest.fn(() => Promise.resolve()),
} satisfies Pick<AuthService, 'requireProjectPermission'>;
return {
audit,
auth,
llm,
service: new AgentService(
audit as unknown as AuditService,
llm as unknown as LlmClient,
auth as unknown as AuthService,
),
};
}
it('creates a DevOps-scoped mock invocation when LLM is not configured', async () => {
it('returns a failed invocation when LLM is not configured', async () => {
const { audit, llm, service } = createService();
const invocation = await service.createInvocation({
type: 'failure-diagnosis',
runId: 'run_001',
projectKey: 'role-user',
promptSummary: 'Analyze latest failed Jenkins log excerpt.',
});
const invocation = await service.createInvocation(
{
type: 'failure-diagnosis',
runId: 'run_001',
projectKey: 'role-user',
promptSummary: 'Analyze latest failed Jenkins log excerpt.',
},
operator,
);
expect(invocation.status).toBe('success');
expect(invocation.resultMarkdown).toContain('失败诊断摘要');
expect(invocation.status).toBe('failed');
expect(invocation.resultMarkdown).toContain('Agent 配置不完整');
expect(llm.complete).not.toHaveBeenCalled();
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'AGENT_INVOCATION_CREATED',
action: 'AGENT_INVOCATION_FAILED',
resourceType: 'agent_invocation',
after: expect.objectContaining({
mode: 'not_configured',
}),
}),
);
});
@@ -66,11 +94,14 @@ describe('AgentService', () => {
llmText: '真实 LLM 运维分析结果',
});
const invocation = await service.createInvocation({
type: 'release-risk',
projectKey: 'access-manage',
promptSummary: 'Analyze production tag risk.',
});
const invocation = await service.createInvocation(
{
type: 'release-risk',
projectKey: 'access-manage',
promptSummary: 'Analyze production tag risk.',
},
operator,
);
expect(invocation.status).toBe('success');
expect(invocation.resultMarkdown).toBe('真实 LLM 运维分析结果');
@@ -94,10 +125,13 @@ describe('AgentService', () => {
const { service } = createService();
await expect(
service.createInvocation({
type: 'general-chat',
promptSummary: 'Tell me anything.',
} as unknown as CreateAgentInvocationInput),
service.createInvocation(
{
type: 'general-chat',
promptSummary: 'Tell me anything.',
} as unknown as CreateAgentInvocationInput,
operator,
),
).rejects.toBeInstanceOf(AppError);
});
});
+48 -23
View File
@@ -1,4 +1,6 @@
import { Injectable } from '@nestjs/common';
import { AuthService } from '../auth/auth.service';
import { AuthenticatedUser } from '../auth/auth.types';
import { AppError } from '../common/errors/app-error';
import { AuditService } from '../audit/audit.service';
import { redactSensitive } from '../common/security/redact-sensitive';
@@ -14,19 +16,6 @@ const allowedTypes: AgentPurpose[] = [
'incident-review',
];
const mockResults: Record<AgentPurpose, string> = {
'release-risk':
'发布风险摘要:当前为第一阶段 mock 分析。真实接入后会读取 Gitea ref diff、历史失败记录和项目部署策略。',
'failure-diagnosis':
'失败诊断摘要:当前为第一阶段 mock 分析。真实接入后会基于 Jenkins progressiveText 日志和 run steps 生成建议。',
'runbook-qa':
'Runbook 回答:当前仅允许围绕 DevOps 平台文档、项目 README 和白名单运维资料回答。',
'release-note':
'发布说明草稿:当前为 mock 草稿。真实接入后会根据 commit、PR 和 tag 信息生成。',
'incident-review':
'事故复盘草稿:当前为 mock 草稿。真实接入后会汇总失败 run、耗时、日志摘要和人工处理记录。',
};
@Injectable()
export class AgentService {
private readonly invocations: AgentInvocation[] = [];
@@ -34,6 +23,7 @@ export class AgentService {
constructor(
private readonly auditService: AuditService,
private readonly llmClient: LlmClient,
private readonly authService: AuthService,
) {}
listInvocations(): AgentInvocation[] {
@@ -44,11 +34,12 @@ export class AgentService {
async createInvocation(
input: CreateAgentInvocationInput,
operator: AuthenticatedUser,
): Promise<AgentInvocation> {
if (!allowedTypes.includes(input.type)) {
throw new AppError(
'VALIDATION_FAILED',
'Unsupported agent invocation type',
'不支持的 Agent 调用类型',
400,
{
type: input.type,
@@ -56,7 +47,8 @@ export class AgentService {
);
}
const llmHealth = this.llmClient.healthSummary();
await this.validateAgentScope(input, operator);
const llmHealth = await this.llmClient.healthSummary();
const baseInvocation = {
id: `agent_${Date.now()}_${this.invocations.length + 1}`,
runId: input.runId,
@@ -79,9 +71,14 @@ export class AgentService {
invocation = {
...baseInvocation,
status: 'success',
resultMarkdown: completion.text || mockResults[input.type],
resultMarkdown: completion.text || 'Agent 返回了空结果,请检查模型配置。',
};
await this.recordInvocationAudit(invocation, 'llm', completion.model);
await this.recordInvocationAudit(
invocation,
operator,
'llm',
completion.model,
);
} catch (error) {
invocation = {
...baseInvocation,
@@ -89,19 +86,21 @@ export class AgentService {
resultMarkdown:
error instanceof Error
? redactSensitive({ message: error.message }).message
: 'LLM invocation failed',
: 'Agent 调用失败',
};
await this.recordInvocationAudit(invocation, 'llm_failed');
await this.recordInvocationAudit(invocation, operator, 'llm_failed');
}
} else {
invocation = {
...baseInvocation,
status: 'success',
resultMarkdown: mockResults[input.type],
status: 'failed',
resultMarkdown:
llmHealth.message ?? 'Agent 配置不完整,请先配置 key、baseURL 和 model。',
};
await this.recordInvocationAudit(
invocation,
'mock',
operator,
'not_configured',
undefined,
llmHealth.message,
);
@@ -111,9 +110,33 @@ export class AgentService {
return invocation;
}
private async validateAgentScope(
input: CreateAgentInvocationInput,
operator: AuthenticatedUser,
): Promise<void> {
if (operator.role === 'super_admin') {
return;
}
if (!input.projectKey) {
throw new AppError(
'FORBIDDEN',
'普通成员只能在已授权项目内使用 Agent',
403,
);
}
await this.authService.requireProjectPermission(
operator,
input.projectKey,
'read',
);
}
private async recordInvocationAudit(
invocation: AgentInvocation,
mode: 'mock' | 'llm' | 'llm_failed',
operator: AuthenticatedUser,
mode: 'llm' | 'llm_failed' | 'not_configured',
model?: string,
reason?: string,
): Promise<void> {
@@ -126,6 +149,8 @@ export class AgentService {
action,
resourceType: 'agent_invocation',
resourceId: invocation.id,
actorId: operator.id,
actorName: operator.account,
after: {
type: invocation.type,
runId: invocation.runId,
+2 -1
View File
@@ -52,7 +52,8 @@ export class CreateAgentInvocationDto {
projectKey?: string;
@ApiProperty({
example: 'Analyze latest failed Jenkins log excerpt for access-manage test.',
description: '本次 Agent 调用的中文问题或任务摘要',
example: '分析 access-manage 测试环境最近一次 Jenkins 失败日志。',
})
promptSummary!: string;
}
+29 -31
View File
@@ -1,34 +1,38 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IntegrationRequestError, IntegrationUnavailableError } from '../common/errors/integration-error';
import { AgentConfigService } from '../agent-config/agent-config.service';
import { AppError } from '../common/errors/app-error';
import { IntegrationRequestError } from '../common/errors/integration-error';
import { redactSensitive } from '../common/security/redact-sensitive';
import { EnvConfig } from '../config/env.schema';
import { LlmCompletionInput, LlmCompletionResult } from './agent.types';
@Injectable()
export class LlmClient {
constructor(private readonly config: ConfigService<EnvConfig, true>) {}
constructor(private readonly agentConfigService: AgentConfigService) {}
healthSummary(): { status: 'ok' | 'not_configured'; message?: string } {
const missing = this.missingConfig();
return missing.length === 0
async healthSummary(): Promise<{
status: 'ok' | 'not_configured';
message?: string;
}> {
const config = await this.agentConfigService.resolveRuntimeConfig();
return config
? { status: 'ok' }
: { status: 'not_configured', message: `Missing ${missing.join(', ')}` };
: {
status: 'not_configured',
message: 'Agent 配置不完整,请配置 key、baseURL 和 model',
};
}
async complete(input: LlmCompletionInput): Promise<LlmCompletionResult> {
this.assertConfigured();
const baseUrl = this.config.get('LLM_BASE_URL', { infer: true }) ?? '';
const apiKey = this.config.get('LLM_API_KEY', { infer: true }) ?? '';
const model = this.config.get('LLM_MODEL', { infer: true }) ?? '';
const response = await fetch(`${baseUrl}/chat/completions`, {
const config = await this.agentConfigService.requireRuntimeConfig();
const response = await fetch(`${this.trimSlash(config.baseURL)}/chat/completions`, {
method: 'POST',
headers: {
authorization: `Bearer ${apiKey}`,
authorization: `Bearer ${config.key}`,
'content-type': 'application/json',
},
body: JSON.stringify({
model,
model: config.model,
messages: [
{ role: 'system', content: input.systemPrompt },
{ role: 'user', content: input.userPrompt },
@@ -37,7 +41,7 @@ export class LlmClient {
});
if (!response.ok) {
throw new IntegrationRequestError('llm', 'LLM request failed', {
throw new IntegrationRequestError('llm', 'Agent 请求失败', {
status: response.status,
purpose: input.purpose,
request: redactSensitive(input),
@@ -51,25 +55,19 @@ export class LlmClient {
return {
text: payload.choices?.[0]?.message?.content ?? '',
model: payload.model ?? model,
model: payload.model ?? config.model,
};
}
private assertConfigured(): void {
const missing = this.missingConfig();
if (missing.length > 0) {
throw new IntegrationUnavailableError('llm', missing);
private trimSlash(value: string): string {
if (!value) {
throw new AppError(
'CONFIG_MISSING',
'Agent baseURL 未配置,请联系超级管理员',
500,
);
}
}
private missingConfig(): string[] {
const entries: Array<[string, string | undefined]> = [
['LLM_BASE_URL', this.config.get('LLM_BASE_URL', { infer: true })],
['LLM_API_KEY', this.config.get('LLM_API_KEY', { infer: true })],
['LLM_MODEL', this.config.get('LLM_MODEL', { infer: true })],
];
return entries.filter(([, value]) => !value).map(([key]) => key);
return value.replace(/\/+$/, '');
}
}
+8
View File
@@ -1,13 +1,17 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AgentConfigModule } from './agent-config/agent-config.module';
import { AgentModule } from './agent/agent.module';
import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module';
import { DeployRunsModule } from './deploy-runs/deploy-runs.module';
import { validateEnv } from './config/env.schema';
import { GiteaModule } from './integrations/gitea/gitea.module';
import { GiteaWebhookModule } from './webhooks/gitea/gitea-webhook.module';
import { HealthModule } from './health/health.module';
import { JenkinsModule } from './integrations/jenkins/jenkins.module';
import { MembersModule } from './members/members.module';
import { MessagesModule } from './messages/messages.module';
import { ProcessDefinitionsModule } from './process-definitions/process-definitions.module';
import { PrismaModule } from './prisma/prisma.module';
import { ProjectsModule } from './projects/projects.module';
@@ -24,6 +28,10 @@ import { WeComModule } from './notifications/wecom/wecom.module';
}),
PrismaModule,
AuditModule,
AuthModule,
MessagesModule,
MembersModule,
AgentConfigModule,
AgentModule,
JenkinsModule,
GiteaModule,
+9 -3
View File
@@ -1,17 +1,23 @@
/**
* 审计日志查询入口只返回脱敏后的最近记录。
*/
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AdminOnlyMessage } from '../auth/admin-only-message.decorator';
import { AuthGuard } from '../auth/auth.guard';
import { PasswordChangeGuard } from '../auth/password-change.guard';
import { SuperAdminGuard } from '../auth/super-admin.guard';
import { AuditRecordSummary, AuditService } from './audit.service';
@ApiTags('audit')
@ApiTags('审计日志')
@Controller('audit-logs')
@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard)
@AdminOnlyMessage('无权限访问审计日志')
export class AuditController {
constructor(private readonly auditService: AuditService) {}
@Get()
@ApiOkResponse({ description: 'Recent sanitized audit records.' })
@ApiOkResponse({ description: '查询最近的脱敏审计记录。' })
async listRecentRecords(): Promise<AuditRecordSummary[]> {
return this.auditService.getRecentRecords();
}
+6
View File
@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const ADMIN_ONLY_MESSAGE_KEY = 'adminOnlyMessage';
export const AdminOnlyMessage = (message: string) =>
SetMetadata(ADMIN_ONLY_MESSAGE_KEY, message);
+143
View File
@@ -0,0 +1,143 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHmac, timingSafeEqual } from 'crypto';
import { AppError } from '../common/errors/app-error';
import { EnvConfig } from '../config/env.schema';
import { AUTH_TOKEN_VERSION } from './auth.constants';
import { AuthenticatedUser } from './auth.types';
import { UserRepository } from './user.repository';
type TokenPayload = {
ver: typeof AUTH_TOKEN_VERSION;
sub: string;
account: string;
role: AuthenticatedUser['role'];
passwordVersion: number;
iat: number;
exp: number;
};
@Injectable()
export class AuthTokenService {
constructor(
private readonly config: ConfigService<EnvConfig, true>,
private readonly userRepository: UserRepository,
) {}
sign(user: AuthenticatedUser): { token: string; expiresAt: string } {
const now = Math.floor(Date.now() / 1000);
const ttlSeconds = this.config.get('AUTH_TOKEN_TTL_SECONDS', {
infer: true,
});
const payload: TokenPayload = {
ver: AUTH_TOKEN_VERSION,
sub: user.id,
account: user.account,
role: user.role,
passwordVersion: user.passwordVersion,
iat: now,
exp: now + ttlSeconds,
};
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString(
'base64url',
);
const signature = this.signPayload(encodedPayload);
return {
token: `${AUTH_TOKEN_VERSION}.${encodedPayload}.${signature}`,
expiresAt: new Date(payload.exp * 1000).toISOString(),
};
}
async verify(token: string): Promise<AuthenticatedUser> {
const payload = this.parseToken(token);
const user = await this.userRepository.findById(payload.sub);
if (!user) {
throw new AppError('AUTH_REQUIRED', '登录已失效,请重新登录', 401);
}
if (user.status !== 'active') {
throw new AppError('FORBIDDEN', '账号已被禁用,请联系超级管理员', 403);
}
if (user.passwordVersion !== payload.passwordVersion) {
throw new AppError('AUTH_REQUIRED', '密码已变更,请重新登录', 401);
}
return this.toAuthenticatedUser(user);
}
private parseToken(token: string): TokenPayload {
const [version, encodedPayload, signature] = token.split('.');
if (version !== AUTH_TOKEN_VERSION || !encodedPayload || !signature) {
throw new AppError('AUTH_REQUIRED', '登录已失效,请重新登录', 401);
}
const expectedSignature = this.signPayload(encodedPayload);
const actual = Buffer.from(signature);
const expected = Buffer.from(expectedSignature);
if (
actual.length !== expected.length ||
!timingSafeEqual(actual, expected)
) {
throw new AppError('AUTH_REQUIRED', '登录已失效,请重新登录', 401);
}
let payload: TokenPayload;
try {
payload = JSON.parse(
Buffer.from(encodedPayload, 'base64url').toString('utf8'),
) as TokenPayload;
} catch {
throw new AppError('AUTH_REQUIRED', '登录已失效,请重新登录', 401);
}
if (payload.exp < Math.floor(Date.now() / 1000)) {
throw new AppError('AUTH_REQUIRED', '登录已过期,请重新登录', 401);
}
return payload;
}
private signPayload(encodedPayload: string): string {
return createHmac('sha256', this.resolveSecret())
.update(encodedPayload)
.digest('base64url');
}
private resolveSecret(): string {
const secret =
this.config.get('AUTH_TOKEN_SECRET', { infer: true }) ??
this.config.get('SECRET_ENCRYPTION_KEY', { infer: true });
if (secret) {
return secret;
}
if (this.config.get('NODE_ENV', { infer: true }) === 'production') {
throw new AppError(
'CONFIG_MISSING',
'生产环境缺少 AUTH_TOKEN_SECRET,不能签发登录令牌',
500,
);
}
return 'devops-platform-local-auth-secret';
}
private toAuthenticatedUser(user: AuthenticatedUser): AuthenticatedUser {
return {
id: user.id,
account: user.account,
displayName: user.displayName,
role: user.role,
status: user.status,
mustChangePassword: user.mustChangePassword,
passwordVersion: user.passwordVersion,
};
}
}
+13
View File
@@ -0,0 +1,13 @@
export const SUPER_ADMIN_SEED = {
id: 'user_super_admin_zhanxi',
account: 'zhanxi',
displayName: '超级管理员',
} as const;
export const LOCAL_SUPER_ADMIN_INITIAL_PASSWORD = 'devops-local-admin';
export const MEMBER_INITIAL_PASSWORD = '111111';
export const AUTH_COOKIE_NAME = 'devops_auth_token';
export const AUTH_TOKEN_VERSION = 'v1';
+78
View File
@@ -0,0 +1,78 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import {
ApiBody,
ApiCreatedResponse,
ApiOkResponse,
ApiTags,
} from '@nestjs/swagger';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { AuthGuard } from './auth.guard';
import {
ChangePasswordDto,
ChangePasswordInput,
ForgotPasswordDto,
ForgotPasswordInput,
LoginDto,
LoginInput,
changePasswordSchema,
forgotPasswordSchema,
loginSchema,
} from './auth.dto';
import { AuthService } from './auth.service';
import { CurrentUser } from './current-user.decorator';
import { AuthenticatedUser, LoginResult } from './auth.types';
@ApiTags('登录鉴权')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
@ApiBody({ type: LoginDto })
@ApiCreatedResponse({ description: '登录成功,返回登录令牌和当前账号信息。' })
async login(
@Body(new ZodValidationPipe(loginSchema)) body: LoginInput,
): Promise<LoginResult> {
return this.authService.login(body);
}
@Get('me')
@UseGuards(AuthGuard)
@ApiOkResponse({ description: '获取当前登录账号信息。' })
getCurrentUser(@CurrentUser() user: AuthenticatedUser): AuthenticatedUser {
return user;
}
@Post('change-password')
@UseGuards(AuthGuard)
@ApiBody({ type: ChangePasswordDto })
@ApiOkResponse({ description: '修改当前登录账号密码。' })
async changePassword(
@CurrentUser() user: AuthenticatedUser,
@Body(new ZodValidationPipe(changePasswordSchema))
body: ChangePasswordInput,
): Promise<AuthenticatedUser> {
return this.authService.changePassword(user, body);
}
@Post('forgot-password')
@ApiBody({ type: ForgotPasswordDto })
@ApiCreatedResponse({
description: '普通成员忘记密码时通知超级管理员处理。',
})
async forgotPassword(
@Body(new ZodValidationPipe(forgotPasswordSchema))
body: ForgotPasswordInput,
): Promise<{ message: string }> {
return this.authService.forgotPassword(body);
}
@Post('logout')
@UseGuards(AuthGuard)
@ApiOkResponse({ description: '退出登录。' })
logout(): { message: string } {
return {
message: '已退出登录',
};
}
}
+43
View File
@@ -0,0 +1,43 @@
import { ApiProperty } from '@nestjs/swagger';
import { z } from 'zod';
export const loginSchema = z.object({
account: z.string().trim().min(1).max(80),
password: z.string().min(1).max(200),
});
export type LoginInput = z.infer<typeof loginSchema>;
export class LoginDto {
@ApiProperty({ example: 'zhanxi', description: '登录账号' })
account!: string;
@ApiProperty({ example: 'change-me-from-server-env', description: '登录密码' })
password!: string;
}
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1).max(200),
newPassword: z.string().min(6).max(200),
});
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
export class ChangePasswordDto {
@ApiProperty({ description: '当前密码' })
currentPassword!: string;
@ApiProperty({ description: '新密码,至少 6 位' })
newPassword!: string;
}
export const forgotPasswordSchema = z.object({
account: z.string().trim().min(1).max(80),
});
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
export class ForgotPasswordDto {
@ApiProperty({ example: 'member01', description: '普通成员账号' })
account!: string;
}
+47
View File
@@ -0,0 +1,47 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { AUTH_COOKIE_NAME } from './auth.constants';
import { AuthTokenService } from './auth-token.service';
import { RequestWithUser } from './auth.types';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly authTokenService: AuthTokenService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<RequestWithUser>();
const token = this.resolveToken(request);
if (!token) {
throw new AppError('AUTH_REQUIRED', '登录已失效,请重新登录', 401);
}
request.user = await this.authTokenService.verify(token);
return true;
}
private resolveToken(request: RequestWithUser): string | undefined {
const authorization = this.firstHeader(request.headers.authorization);
if (authorization?.startsWith('Bearer ')) {
return authorization.slice('Bearer '.length).trim();
}
const cookie = this.firstHeader(request.headers.cookie);
if (!cookie) {
return undefined;
}
const entries = cookie.split(';').map((item) => item.trim().split('='));
const tokenEntry = entries.find(([name]) => name === AUTH_COOKIE_NAME);
return tokenEntry?.[1] ? decodeURIComponent(tokenEntry[1]) : undefined;
}
private firstHeader(
value: string | string[] | undefined,
): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { Global, Module, forwardRef } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { MessagesModule } from '../messages/messages.module';
import { AuthController } from './auth.controller';
import { AuthGuard } from './auth.guard';
import { AuthService } from './auth.service';
import { AuthTokenService } from './auth-token.service';
import { PasswordChangeGuard } from './password-change.guard';
import { PasswordService } from './password.service';
import { SuperAdminGuard } from './super-admin.guard';
import { UserRepository } from './user.repository';
@Global()
@Module({
imports: [AuditModule, forwardRef(() => MessagesModule)],
controllers: [AuthController],
providers: [
AuthGuard,
AuthService,
AuthTokenService,
PasswordChangeGuard,
PasswordService,
SuperAdminGuard,
UserRepository,
],
exports: [
AuthGuard,
AuthService,
AuthTokenService,
PasswordChangeGuard,
PasswordService,
SuperAdminGuard,
UserRepository,
],
})
export class AuthModule {}
+179
View File
@@ -0,0 +1,179 @@
import { AuditService } from '../audit/audit.service';
import { MessagesService } from '../messages/messages.service';
import { AppError } from '../common/errors/app-error';
import { EnvConfig } from '../config/env.schema';
import { AuthTokenService } from './auth-token.service';
import { AuthService } from './auth.service';
import { MEMBER_INITIAL_PASSWORD } from './auth.constants';
import { PasswordService } from './password.service';
import { UserRepository } from './user.repository';
import { UserRecord } from './auth.types';
import { ConfigService } from '@nestjs/config';
const TEST_ADMIN_PASSWORD = 'test-admin-password';
describe('AuthService', () => {
const passwordService = new PasswordService();
async function createService() {
const adminHash = await passwordService.hashPassword(TEST_ADMIN_PASSWORD);
const memberHash = await passwordService.hashPassword(MEMBER_INITIAL_PASSWORD);
const users = new Map<string, UserRecord>();
const admin: UserRecord = {
id: 'user_admin',
account: 'zhanxi',
displayName: '超级管理员',
role: 'super_admin',
status: 'active',
passwordHash: adminHash,
mustChangePassword: false,
passwordVersion: 1,
createdAt: '2026-06-11T00:00:00.000Z',
updatedAt: '2026-06-11T00:00:00.000Z',
projectPermissions: [],
};
const member: UserRecord = {
id: 'user_member',
account: 'member01',
displayName: '成员一',
role: 'member',
status: 'active',
passwordHash: memberHash,
mustChangePassword: true,
passwordVersion: 1,
createdAt: '2026-06-11T00:00:00.000Z',
updatedAt: '2026-06-11T00:00:00.000Z',
projectPermissions: [],
};
users.set(admin.id, admin);
users.set(member.id, member);
const repository = {
ensureSuperAdmin: jest.fn(),
findByAccount: jest.fn((account: string) =>
Promise.resolve(
[...users.values()].find((user) => user.account === account) ?? null,
),
),
findById: jest.fn((id: string) => Promise.resolve(users.get(id) ?? null)),
markLogin: jest.fn(() => Promise.resolve()),
updatePassword: jest.fn(
(id: string, passwordHash: string, mustChangePassword: boolean) => {
const user = users.get(id);
if (!user) {
return Promise.reject(new Error('成员不存在'));
}
const updatedUser: UserRecord = {
...user,
passwordHash,
mustChangePassword,
passwordVersion: user.passwordVersion + 1,
};
users.set(id, updatedUser);
return Promise.resolve(updatedUser);
},
),
getProjectPermissionLevel: jest.fn(),
listAllowedProjectKeys: jest.fn(),
} satisfies Partial<UserRepository>;
const token = {
sign: jest.fn(() => ({
token: 'token_001',
expiresAt: '2026-06-11T12:00:00.000Z',
})),
} satisfies Pick<AuthTokenService, 'sign'>;
const messages = {
createPasswordResetRequest: jest.fn(() =>
Promise.resolve({
id: 'message_001',
type: 'password_reset_request' as const,
status: 'pending' as const,
title: '成员密码重置请求',
content: '账号 member01 的成员请求重置密码,请及时处理。',
requesterId: 'user_member',
requesterAccount: 'member01',
createdAt: '2026-06-11T00:00:00.000Z',
updatedAt: '2026-06-11T00:00:00.000Z',
}),
),
} satisfies Pick<MessagesService, 'createPasswordResetRequest'>;
const audit = {
record: jest.fn(() => Promise.resolve()),
} satisfies Pick<AuditService, 'record'>;
const config = {
get: jest.fn((key: keyof EnvConfig) => {
if (key === 'SUPER_ADMIN_INITIAL_PASSWORD') {
return TEST_ADMIN_PASSWORD;
}
if (key === 'NODE_ENV') {
return 'test';
}
return undefined;
}),
};
return {
admin,
audit,
member,
messages,
repository,
service: new AuthService(
config as unknown as ConfigService<EnvConfig, true>,
repository as unknown as UserRepository,
passwordService,
token as unknown as AuthTokenService,
messages as unknown as MessagesService,
audit as unknown as AuditService,
),
};
}
it('使用内置超级管理员账号登录成功', async () => {
const { repository, service } = await createService();
const result = await service.login({
account: 'zhanxi',
password: TEST_ADMIN_PASSWORD,
});
expect(result.token).toBe('token_001');
expect(result.user.role).toBe('super_admin');
expect(repository.markLogin).toHaveBeenCalledWith('user_admin');
});
it('拒绝超级管理员使用忘记密码流程', async () => {
const { service } = await createService();
await expect(
service.forgotPassword({ account: 'zhanxi' }),
).rejects.toBeInstanceOf(AppError);
});
it('普通成员忘记密码会生成通知消息', async () => {
const { messages, service } = await createService();
const result = await service.forgotPassword({ account: 'member01' });
expect(result.message).toBe('已通知超级管理员协助重置密码');
expect(messages.createPasswordResetRequest).toHaveBeenCalledWith(
expect.objectContaining({ account: 'member01' }),
);
});
it('普通成员改密不能继续使用初始密码', async () => {
const { member, service } = await createService();
await expect(
service.changePassword(member, {
currentPassword: MEMBER_INITIAL_PASSWORD,
newPassword: MEMBER_INITIAL_PASSWORD,
}),
).rejects.toBeInstanceOf(AppError);
});
});
+252
View File
@@ -0,0 +1,252 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuditService } from '../audit/audit.service';
import { AppError } from '../common/errors/app-error';
import { EnvConfig } from '../config/env.schema';
import { MessagesService } from '../messages/messages.service';
import {
LOCAL_SUPER_ADMIN_INITIAL_PASSWORD,
MEMBER_INITIAL_PASSWORD,
} from './auth.constants';
import { AuthTokenService } from './auth-token.service';
import {
ChangePasswordInput,
ForgotPasswordInput,
LoginInput,
} from './auth.dto';
import {
AuthenticatedUser,
LoginResult,
ProjectPermissionLevel,
UserRecord,
} from './auth.types';
import { PasswordService } from './password.service';
import { UserRepository } from './user.repository';
@Injectable()
export class AuthService implements OnModuleInit {
constructor(
private readonly config: ConfigService<EnvConfig, true>,
private readonly userRepository: UserRepository,
private readonly passwordService: PasswordService,
private readonly authTokenService: AuthTokenService,
private readonly messagesService: MessagesService,
private readonly auditService: AuditService,
) {}
async onModuleInit(): Promise<void> {
const passwordHash = await this.passwordService.hashPassword(
this.resolveSuperAdminInitialPassword(),
);
await this.userRepository.ensureSuperAdmin(passwordHash);
}
async login(input: LoginInput): Promise<LoginResult> {
const user = await this.userRepository.findByAccount(input.account);
if (!user) {
throw new AppError('AUTH_REQUIRED', '账号或密码错误', 401);
}
if (user.status !== 'active') {
throw new AppError('FORBIDDEN', '账号已被禁用,请联系超级管理员', 403);
}
const passwordMatched = await this.passwordService.verifyPassword(
input.password,
user.passwordHash,
);
if (!passwordMatched) {
throw new AppError('AUTH_REQUIRED', '账号或密码错误', 401);
}
await this.userRepository.markLogin(user.id);
await this.auditService.record({
action: 'USER_LOGIN',
resourceType: 'user',
resourceId: user.id,
actorId: user.id,
actorName: user.account,
after: {
role: user.role,
mustChangePassword: user.mustChangePassword,
},
});
const authenticatedUser = this.toAuthenticatedUser(user);
const tokenResult = this.authTokenService.sign(authenticatedUser);
return {
token: tokenResult.token,
expiresAt: tokenResult.expiresAt,
user: authenticatedUser,
};
}
async changePassword(
currentUser: AuthenticatedUser,
input: ChangePasswordInput,
): Promise<AuthenticatedUser> {
const user = await this.requireUser(currentUser.id);
const passwordMatched = await this.passwordService.verifyPassword(
input.currentPassword,
user.passwordHash,
);
if (!passwordMatched) {
throw new AppError('VALIDATION_FAILED', '当前密码不正确', 400);
}
if (input.currentPassword === input.newPassword) {
throw new AppError('VALIDATION_FAILED', '新密码不能与当前密码相同', 400);
}
if (user.role === 'member' && input.newPassword === MEMBER_INITIAL_PASSWORD) {
throw new AppError(
'VALIDATION_FAILED',
'新密码不能继续使用初始密码',
400,
);
}
const passwordHash = await this.passwordService.hashPassword(
input.newPassword,
);
const updatedUser = await this.userRepository.updatePassword(
user.id,
passwordHash,
false,
);
await this.auditService.record({
action: 'USER_PASSWORD_CHANGED',
resourceType: 'user',
resourceId: user.id,
actorId: user.id,
actorName: user.account,
after: {
mustChangePassword: false,
},
});
return this.toAuthenticatedUser(updatedUser);
}
async forgotPassword(
input: ForgotPasswordInput,
): Promise<{ message: string }> {
const user = await this.userRepository.findByAccount(input.account);
if (!user) {
throw new AppError('RESOURCE_NOT_FOUND', '账号不存在,请确认后重试', 404);
}
if (user.role === 'super_admin') {
throw new AppError(
'FORBIDDEN',
'超级管理员不能使用忘记密码流程,请通过服务器运维方式安全重置',
403,
);
}
if (user.status !== 'active') {
throw new AppError('FORBIDDEN', '账号已被禁用,请联系超级管理员', 403);
}
await this.messagesService.createPasswordResetRequest(user);
await this.auditService.record({
action: 'PASSWORD_RESET_REQUESTED',
resourceType: 'user',
resourceId: user.id,
actorId: user.id,
actorName: user.account,
after: {
requestMerged: true,
},
});
return {
message: '已通知超级管理员协助重置密码',
};
}
async requireProjectPermission(
user: AuthenticatedUser,
projectKey: string,
requiredLevel: Exclude<ProjectPermissionLevel, 'none'>,
): Promise<void> {
if (user.role === 'super_admin') {
return;
}
const level = await this.userRepository.getProjectPermissionLevel(
user,
projectKey,
);
if (level === 'none') {
throw new AppError('FORBIDDEN', '无权限访问该项目', 403, {
projectKey,
});
}
if (requiredLevel === 'build' && level !== 'build') {
throw new AppError('FORBIDDEN', '无权限执行该项目构建', 403, {
projectKey,
});
}
}
async listAllowedProjectKeys(user: AuthenticatedUser): Promise<string[]> {
return this.userRepository.listAllowedProjectKeys(user);
}
assertSuperAdmin(user: AuthenticatedUser, message = '无权限访问该功能'): void {
if (user.role !== 'super_admin') {
throw new AppError('FORBIDDEN', message, 403);
}
}
toAuthenticatedUser(user: UserRecord): AuthenticatedUser {
return {
id: user.id,
account: user.account,
displayName: user.displayName,
role: user.role,
status: user.status,
mustChangePassword: user.mustChangePassword,
passwordVersion: user.passwordVersion,
};
}
private async requireUser(userId: string): Promise<UserRecord> {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new AppError('AUTH_REQUIRED', '登录已失效,请重新登录', 401);
}
return user;
}
private resolveSuperAdminInitialPassword(): string {
const password = this.config.get('SUPER_ADMIN_INITIAL_PASSWORD', {
infer: true,
});
if (password) {
return password;
}
if (this.config.get('NODE_ENV', { infer: true }) === 'production') {
throw new AppError(
'CONFIG_MISSING',
'生产环境缺少 SUPER_ADMIN_INITIAL_PASSWORD,不能初始化超级管理员',
500,
);
}
return LOCAL_SUPER_ADMIN_INITIAL_PASSWORD;
}
}
+57
View File
@@ -0,0 +1,57 @@
export type AuthRole = 'super_admin' | 'member';
export type AuthStatus = 'active' | 'disabled';
export type ProjectPermissionLevel = 'none' | 'read' | 'build';
export type AuthenticatedUser = {
id: string;
account: string;
displayName: string;
role: AuthRole;
status: AuthStatus;
mustChangePassword: boolean;
passwordVersion: number;
};
export type UserRecord = AuthenticatedUser & {
passwordHash: string;
createdAt: string;
updatedAt: string;
lastLoginAt?: string;
createdById?: string;
projectPermissions: UserProjectPermission[];
};
export type UserProjectPermission = {
projectId: string;
projectKey: string;
projectName: string;
level: Exclude<ProjectPermissionLevel, 'none'>;
};
export type UserSummary = Omit<UserRecord, 'passwordHash'> & {
hasPendingPasswordResetRequest: boolean;
};
export type CreateMemberInput = {
account: string;
displayName: string;
permissions?: ProjectPermissionInput[];
};
export type ProjectPermissionInput = {
projectKey: string;
level: ProjectPermissionLevel;
};
export type LoginResult = {
token: string;
user: AuthenticatedUser;
expiresAt: string;
};
export type RequestWithUser = {
headers: Record<string, string | string[] | undefined>;
user?: AuthenticatedUser;
};
+9
View File
@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthenticatedUser, RequestWithUser } from './auth.types';
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): AuthenticatedUser => {
const request = context.switchToHttp().getRequest<RequestWithUser>();
return request.user as AuthenticatedUser;
},
);
+21
View File
@@ -0,0 +1,21 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { RequestWithUser } from './auth.types';
@Injectable()
export class PasswordChangeGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
if (user?.role === 'member' && user.mustChangePassword) {
throw new AppError(
'PASSWORD_CHANGE_REQUIRED',
'首次登录必须修改密码后才能访问平台功能',
403,
);
}
return true;
}
}
+40
View File
@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'crypto';
import { promisify } from 'util';
const scrypt = promisify(scryptCallback);
const PASSWORD_HASH_PREFIX = 'scrypt-sha256-v1';
const PASSWORD_KEY_LENGTH = 64;
@Injectable()
export class PasswordService {
async hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString('base64url');
const derivedKey = (await scrypt(
password,
salt,
PASSWORD_KEY_LENGTH,
)) as Buffer;
return [
PASSWORD_HASH_PREFIX,
salt,
derivedKey.toString('base64url'),
].join(':');
}
async verifyPassword(password: string, passwordHash: string): Promise<boolean> {
const [prefix, salt, encodedHash] = passwordHash.split(':');
if (prefix !== PASSWORD_HASH_PREFIX || !salt || !encodedHash) {
return false;
}
const expected = Buffer.from(encodedHash, 'base64url');
const actual = (await scrypt(password, salt, expected.length)) as Buffer;
return (
expected.length === actual.length && timingSafeEqual(expected, actual)
);
}
}
+29
View File
@@ -0,0 +1,29 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AppError } from '../common/errors/app-error';
import {
ADMIN_ONLY_MESSAGE_KEY,
} from './admin-only-message.decorator';
import { RequestWithUser } from './auth.types';
@Injectable()
export class SuperAdminGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
if (user?.role !== 'super_admin') {
const message =
this.reflector.getAllAndOverride<string>(ADMIN_ONLY_MESSAGE_KEY, [
context.getHandler(),
context.getClass(),
]) ?? '无权限访问该功能';
throw new AppError('FORBIDDEN', message, 403);
}
return true;
}
}
+514
View File
@@ -0,0 +1,514 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
Prisma,
ProjectPermissionLevel as PrismaProjectPermissionLevel,
UserRole as PrismaUserRole,
UserStatus as PrismaUserStatus,
} from '@prisma/client';
import { AppError } from '../common/errors/app-error';
import { EnvConfig } from '../config/env.schema';
import { PrismaService } from '../prisma/prisma.service';
import { seedProjects } from '../projects/project.seed';
import { SUPER_ADMIN_SEED } from './auth.constants';
import {
AuthenticatedUser,
CreateMemberInput,
ProjectPermissionInput,
ProjectPermissionLevel,
UserRecord,
} from './auth.types';
type UserRecordWithPermissions = Prisma.UserGetPayload<{
include: {
projectPermissions: {
include: {
project: true;
};
};
};
}>;
type CreateUserRecordInput = CreateMemberInput & {
id?: string;
passwordHash: string;
role: AuthenticatedUser['role'];
mustChangePassword: boolean;
createdById?: string;
};
@Injectable()
export class UserRepository {
private readonly memoryUsers: UserRecord[] = [];
constructor(
private readonly config: ConfigService<EnvConfig, true>,
private readonly prisma: PrismaService,
) {}
async ensureSuperAdmin(passwordHash: string): Promise<UserRecord> {
if (!this.useDatabase()) {
const existing = await this.findByAccount(SUPER_ADMIN_SEED.account);
if (existing) {
existing.role = 'super_admin';
existing.status = 'active';
existing.displayName = SUPER_ADMIN_SEED.displayName;
existing.mustChangePassword = false;
existing.updatedAt = new Date().toISOString();
return existing;
}
return this.createUser({
id: SUPER_ADMIN_SEED.id,
account: SUPER_ADMIN_SEED.account,
displayName: SUPER_ADMIN_SEED.displayName,
passwordHash,
role: 'super_admin',
mustChangePassword: false,
});
}
const user = await this.prisma.user.upsert({
where: {
account: SUPER_ADMIN_SEED.account,
},
create: {
id: SUPER_ADMIN_SEED.id,
account: SUPER_ADMIN_SEED.account,
displayName: SUPER_ADMIN_SEED.displayName,
passwordHash,
role: PrismaUserRole.SUPER_ADMIN,
status: PrismaUserStatus.ACTIVE,
mustChangePassword: false,
},
update: {
displayName: SUPER_ADMIN_SEED.displayName,
role: PrismaUserRole.SUPER_ADMIN,
status: PrismaUserStatus.ACTIVE,
mustChangePassword: false,
},
include: this.includePermissions(),
});
return this.toRecord(user);
}
async findByAccount(account: string): Promise<UserRecord | null> {
if (!this.useDatabase()) {
return (
this.memoryUsers.find((user) => user.account === account.trim()) ?? null
);
}
const user = await this.prisma.user.findUnique({
where: {
account: account.trim(),
},
include: this.includePermissions(),
});
return user ? this.toRecord(user) : null;
}
async findById(id: string): Promise<UserRecord | null> {
if (!this.useDatabase()) {
return this.memoryUsers.find((user) => user.id === id) ?? null;
}
const user = await this.prisma.user.findUnique({
where: { id },
include: this.includePermissions(),
});
return user ? this.toRecord(user) : null;
}
async listUsers(): Promise<UserRecord[]> {
if (!this.useDatabase()) {
return [...this.memoryUsers].sort((left, right) =>
left.createdAt.localeCompare(right.createdAt),
);
}
const users = await this.prisma.user.findMany({
include: this.includePermissions(),
orderBy: [
{
role: 'desc',
},
{
createdAt: 'asc',
},
],
});
return users.map((user) => this.toRecord(user));
}
async createUser(input: CreateUserRecordInput): Promise<UserRecord> {
if (!this.useDatabase()) {
const now = new Date().toISOString();
const user: UserRecord = {
id: input.id ?? `user_${Date.now()}_${this.memoryUsers.length + 1}`,
account: input.account.trim(),
displayName: input.displayName.trim(),
role: input.role,
status: 'active',
passwordHash: input.passwordHash,
mustChangePassword: input.mustChangePassword,
passwordVersion: 1,
createdById: input.createdById,
createdAt: now,
updatedAt: now,
projectPermissions: [],
};
this.memoryUsers.push(user);
return user;
}
const user = await this.prisma.user.create({
data: {
id: input.id,
account: input.account.trim(),
displayName: input.displayName.trim(),
passwordHash: input.passwordHash,
role: this.toPrismaRole(input.role),
status: PrismaUserStatus.ACTIVE,
mustChangePassword: input.mustChangePassword,
createdById: input.createdById,
},
include: this.includePermissions(),
});
return this.toRecord(user);
}
async updatePassword(
userId: string,
passwordHash: string,
mustChangePassword: boolean,
): Promise<UserRecord> {
if (!this.useDatabase()) {
const user = this.requireMemoryUser(userId);
user.passwordHash = passwordHash;
user.mustChangePassword = mustChangePassword;
user.passwordVersion += 1;
user.updatedAt = new Date().toISOString();
return user;
}
const user = await this.prisma.user.update({
where: { id: userId },
data: {
passwordHash,
mustChangePassword,
passwordVersion: {
increment: 1,
},
},
include: this.includePermissions(),
});
return this.toRecord(user);
}
async markLogin(userId: string): Promise<void> {
if (!this.useDatabase()) {
const user = this.memoryUsers.find((item) => item.id === userId);
if (user) {
user.lastLoginAt = new Date().toISOString();
}
return;
}
await this.prisma.user.update({
where: { id: userId },
data: {
lastLoginAt: new Date(),
},
});
}
async setStatus(
userId: string,
status: Exclude<AuthenticatedUser['status'], 'disabled'> | 'disabled',
): Promise<UserRecord> {
if (!this.useDatabase()) {
const user = this.requireMemoryUser(userId);
user.status = status;
user.updatedAt = new Date().toISOString();
return user;
}
const user = await this.prisma.user.update({
where: { id: userId },
data: {
status:
status === 'active'
? PrismaUserStatus.ACTIVE
: PrismaUserStatus.DISABLED,
},
include: this.includePermissions(),
});
return this.toRecord(user);
}
async deleteUser(userId: string): Promise<void> {
if (!this.useDatabase()) {
const index = this.memoryUsers.findIndex((user) => user.id === userId);
if (index >= 0) {
this.memoryUsers.splice(index, 1);
}
return;
}
await this.prisma.user.delete({
where: { id: userId },
});
}
async replaceProjectPermissions(
userId: string,
permissions: ProjectPermissionInput[],
): Promise<UserRecord> {
const normalizedPermissions = permissions.filter(
(permission) => permission.level !== 'none',
);
if (!this.useDatabase()) {
const user = this.requireMemoryUser(userId);
user.projectPermissions = normalizedPermissions.map((permission) => {
const project = this.findSeedProject(permission.projectKey);
return {
projectId: project.id,
projectKey: project.key,
projectName: project.name,
level: permission.level as Exclude<ProjectPermissionLevel, 'none'>,
};
});
user.updatedAt = new Date().toISOString();
return user;
}
const projects = await this.prisma.project.findMany({
where: {
key: {
in: normalizedPermissions.map((permission) => permission.projectKey),
},
},
});
const missingProjectKeys = normalizedPermissions
.map((permission) => permission.projectKey)
.filter((projectKey) =>
projects.every((project) => project.key !== projectKey),
);
if (missingProjectKeys.length > 0) {
throw new AppError(
'RESOURCE_NOT_FOUND',
`项目不存在:${missingProjectKeys.join('、')}`,
404,
{ projectKeys: missingProjectKeys },
);
}
await this.prisma.$transaction(async (tx) => {
await tx.projectMemberPermission.deleteMany({
where: { userId },
});
if (normalizedPermissions.length > 0) {
await tx.projectMemberPermission.createMany({
data: normalizedPermissions.map((permission) => {
const project = projects.find(
(item) => item.key === permission.projectKey,
);
if (!project) {
throw new Error('项目授权校验异常');
}
return {
userId,
projectId: project.id,
level: this.toPrismaPermissionLevel(permission.level),
};
}),
});
}
});
const user = await this.findById(userId);
if (!user) {
throw new AppError('RESOURCE_NOT_FOUND', '成员不存在', 404, { userId });
}
return user;
}
async getProjectPermissionLevel(
user: AuthenticatedUser,
projectKey: string,
): Promise<ProjectPermissionLevel> {
if (user.role === 'super_admin') {
return 'build';
}
if (!this.useDatabase()) {
const currentUser = this.memoryUsers.find((item) => item.id === user.id);
return (
currentUser?.projectPermissions.find(
(permission) => permission.projectKey === projectKey,
)?.level ?? 'none'
);
}
const permission = await this.prisma.projectMemberPermission.findFirst({
where: {
userId: user.id,
project: {
key: projectKey,
},
},
});
return permission ? this.toPermissionLevel(permission.level) : 'none';
}
async listAllowedProjectKeys(user: AuthenticatedUser): Promise<string[]> {
if (user.role === 'super_admin') {
if (!this.useDatabase()) {
return seedProjects.map((project) => project.key);
}
const projects = await this.prisma.project.findMany({
select: {
key: true,
},
});
return projects.map((project) => project.key);
}
if (!this.useDatabase()) {
const currentUser = this.memoryUsers.find((item) => item.id === user.id);
return (
currentUser?.projectPermissions.map(
(permission) => permission.projectKey,
) ?? []
);
}
const permissions = await this.prisma.projectMemberPermission.findMany({
where: {
userId: user.id,
},
include: {
project: true,
},
});
return permissions.map((permission) => permission.project.key);
}
private requireMemoryUser(userId: string): UserRecord {
const user = this.memoryUsers.find((item) => item.id === userId);
if (!user) {
throw new AppError('RESOURCE_NOT_FOUND', '成员不存在', 404, { userId });
}
return user;
}
private findSeedProject(projectKey: string) {
const project = seedProjects.find((item) => item.key === projectKey);
if (!project) {
throw new AppError(
'RESOURCE_NOT_FOUND',
`项目不存在:${projectKey}`,
404,
{ projectKey },
);
}
return project;
}
private includePermissions(): {
projectPermissions: {
include: {
project: true;
};
};
} {
return {
projectPermissions: {
include: {
project: true,
},
},
};
}
private toRecord(user: UserRecordWithPermissions): UserRecord {
return {
id: user.id,
account: user.account,
displayName: user.displayName,
role:
user.role === PrismaUserRole.SUPER_ADMIN ? 'super_admin' : 'member',
status: user.status === PrismaUserStatus.ACTIVE ? 'active' : 'disabled',
passwordHash: user.passwordHash,
mustChangePassword: user.mustChangePassword,
passwordVersion: user.passwordVersion,
lastLoginAt: user.lastLoginAt?.toISOString(),
createdById: user.createdById ?? undefined,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
projectPermissions: user.projectPermissions.map((permission) => ({
projectId: permission.projectId,
projectKey: permission.project.key,
projectName: permission.project.name,
level: this.toPermissionLevel(permission.level),
})),
};
}
private toPrismaRole(role: AuthenticatedUser['role']): PrismaUserRole {
return role === 'super_admin'
? PrismaUserRole.SUPER_ADMIN
: PrismaUserRole.MEMBER;
}
private toPermissionLevel(
level: PrismaProjectPermissionLevel,
): Exclude<ProjectPermissionLevel, 'none'> {
return level === PrismaProjectPermissionLevel.BUILD ? 'build' : 'read';
}
private toPrismaPermissionLevel(
level: ProjectPermissionLevel,
): PrismaProjectPermissionLevel {
return level === 'build'
? PrismaProjectPermissionLevel.BUILD
: PrismaProjectPermissionLevel.READ;
}
private useDatabase(): boolean {
return (
this.config.get('USE_DATABASE_READS', { infer: true }) &&
Boolean(this.config.get('DATABASE_URL', { infer: true }))
);
}
}
+4
View File
@@ -1,7 +1,11 @@
export type AppErrorCode =
| 'AUTH_REQUIRED'
| 'CONFIG_MISSING'
| 'CONFLICT'
| 'FORBIDDEN'
| 'INTEGRATION_UNAVAILABLE'
| 'INTEGRATION_REQUEST_FAILED'
| 'PASSWORD_CHANGE_REQUIRED'
| 'RESOURCE_NOT_FOUND'
| 'VALIDATION_FAILED'
| 'UNEXPECTED_ERROR';
+1 -1
View File
@@ -4,7 +4,7 @@ export class IntegrationUnavailableError extends AppError {
constructor(integration: string, missing: string[]) {
super(
'INTEGRATION_UNAVAILABLE',
`${integration} integration is not configured`,
`${integration} 集成尚未配置`,
503,
{ integration, missing },
);
+3 -3
View File
@@ -60,7 +60,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
return {
statusCode: exception.getStatus(),
code: 'VALIDATION_FAILED',
message: exception.message,
message: '请求处理失败',
details: { response: exception.getResponse() },
};
}
@@ -69,14 +69,14 @@ export class AllExceptionsFilter implements ExceptionFilter {
return {
statusCode: 500,
code: 'UNEXPECTED_ERROR',
message: exception.message,
message: '系统异常,请联系管理员',
};
}
return {
statusCode: 500,
code: 'UNEXPECTED_ERROR',
message: 'Unexpected error',
message: '系统异常,请联系管理员',
};
}
}
+26 -2
View File
@@ -15,13 +15,13 @@ export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
if (!result.success) {
throw new AppError(
'VALIDATION_FAILED',
'Request body validation failed',
'请求参数校验失败',
400,
{
issues: result.error.issues.map((issue: z.core.$ZodIssue) => ({
path: issue.path.join('.'),
code: issue.code,
message: issue.message,
message: this.toChineseIssueMessage(issue),
})),
},
);
@@ -29,4 +29,28 @@ export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
return result.data;
}
private toChineseIssueMessage(issue: z.core.$ZodIssue): string {
if (issue.code === 'invalid_type') {
return '字段类型不正确或缺少必填字段';
}
if (issue.code === 'too_small') {
return '字段长度或数值小于允许范围';
}
if (issue.code === 'too_big') {
return '字段长度或数值超过允许范围';
}
if (issue.code === 'invalid_format') {
return '字段格式不正确';
}
if (issue.code === 'invalid_value') {
return '字段取值不在允许范围内';
}
return '字段校验未通过';
}
}
+6 -1
View File
@@ -52,6 +52,11 @@ export const envSchema = z.object({
USE_DATABASE_READS: booleanFromEnv,
PRISMA_CONNECT_ON_BOOT: booleanFromEnv,
REDIS_URL: optionalString,
AUTH_TOKEN_SECRET: optionalString,
AUTH_TOKEN_TTL_SECONDS: positiveIntegerFromEnv(43200),
SUPER_ADMIN_ACCOUNT: optionalString,
SUPER_ADMIN_DISPLAY_NAME: optionalString,
SUPER_ADMIN_INITIAL_PASSWORD: optionalString,
JENKINS_BASE_URL: optionalUrl,
JENKINS_USERNAME: optionalString,
JENKINS_API_TOKEN: optionalString,
@@ -80,7 +85,7 @@ export function validateEnv(config: Record<string, unknown>): EnvConfig {
const details = result.error.issues
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
.join('; ');
throw new Error(`Invalid environment configuration: ${details}`);
throw new Error(`环境变量配置无效:${details}`);
}
return result.data;
+8 -3
View File
@@ -17,11 +17,13 @@ export const createDeployRunSchema = z.object({
projectKey: z.string().trim().min(1).max(80),
environment: z.enum(['test', 'production']),
ref: z.string().trim().min(1).max(200),
operator: z.string().trim().min(1).max(120),
operator: optionalTrimmedString(120),
remark: optionalTrimmedString(500),
idempotencyKey: optionalTrimmedString(260),
});
export type CreateDeployRunRequestInput = z.infer<typeof createDeployRunSchema>;
export class CreateDeployRunDto {
@ApiProperty({ example: 'access-manage' })
projectKey!: string;
@@ -32,8 +34,11 @@ export class CreateDeployRunDto {
@ApiProperty({ example: 'develop' })
ref!: string;
@ApiProperty({ example: 'operator@example.com' })
operator!: string;
@ApiPropertyOptional({
example: 'operator@example.com',
description: '兼容旧前端字段;后端会优先使用当前登录账号',
})
operator?: string;
@ApiPropertyOptional({ example: 'Manual test deploy before release.' })
remark?: string;
@@ -121,10 +121,10 @@ describe('DeployExecutionService', () => {
};
}
function createRun(
async function createRun(
repository: DeployRunRepository,
idempotencyKey: string,
): DeployRunSummary {
): Promise<DeployRunSummary> {
return repository.create({
...input,
idempotencyKey,
@@ -143,7 +143,7 @@ describe('DeployExecutionService', () => {
},
notificationStatus: 'skipped',
});
const run = createRun(repository, 'run_dry_001');
const run = await createRun(repository, 'run_dry_001');
const result = await service.execute(run, project);
@@ -169,7 +169,7 @@ describe('DeployExecutionService', () => {
notificationStatus: 'sent',
queueId: 'jenkins_queue_42',
});
const run = createRun(repository, 'run_queue_001');
const run = await createRun(repository, 'run_queue_001');
const result = await service.execute(run, project);
@@ -207,7 +207,10 @@ describe('DeployExecutionService', () => {
queueBuildNumber: 18,
buildResult: 'SUCCESS',
});
const run = await service.execute(createRun(repository, 'run_success_001'), project);
const run = await service.execute(
await createRun(repository, 'run_success_001'),
project,
);
const result = await service.syncJenkinsRun(run, project);
@@ -234,15 +237,18 @@ describe('DeployExecutionService', () => {
buildResult: 'FAILURE',
logText: ['install ok', 'build failed', 'token=secret-value'].join('\n'),
});
const run = await service.execute(createRun(repository, 'run_failure_001'), project);
const run = await service.execute(
await createRun(repository, 'run_failure_001'),
project,
);
const result = await service.syncJenkinsRun(run, project);
expect(result.status).toBe('failed');
expect(result.failureSummary).toBe('Jenkins build #19 FAILURE');
expect(result.failureSummary).toBe('Jenkins 构建 #19 FAILURE');
expect(step(result, 'jenkins-build')?.status).toBe('failed');
expect(step(result, 'jenkins-build')?.errorSummary).toBe(
'Jenkins build #19 FAILURE',
'Jenkins 构建 #19 FAILURE',
);
expect(step(result, 'jenkins-build')?.logExcerpt).toContain('build failed');
expect(step(result, 'notify-wecom')?.status).toBe('skipped');
+10 -10
View File
@@ -194,7 +194,7 @@ export class DeployExecutionService {
currentRun.id,
'jenkins-build',
'running',
`Jenkins build #${queueItem.buildNumber} is running.`,
`Jenkins 构建 #${queueItem.buildNumber} 正在运行。`,
);
await this.auditService.record({
@@ -236,7 +236,7 @@ export class DeployExecutionService {
currentRun.id,
'jenkins-build',
'running',
`Jenkins build #${buildStatus.buildNumber} is running.`,
`Jenkins 构建 #${buildStatus.buildNumber} 正在运行。`,
);
}
@@ -261,7 +261,7 @@ export class DeployExecutionService {
if (health.status === 'not_configured') {
const reason =
health.message ??
'Jenkins is not configured; simulated build completed.';
'Jenkins 尚未配置,已完成本地模拟构建。';
const currentRun = await this.completeStep(
run.id,
'jenkins-build',
@@ -286,7 +286,7 @@ export class DeployExecutionService {
if (!environment.jenkinsJobPath) {
throw new AppError(
'VALIDATION_FAILED',
`Project ${project.key} ${run.environment} Jenkins job path is empty`,
`项目 ${project.key} ${run.environment} 环境 Jenkins Job 路径为空`,
400,
{ projectKey: project.key, environment: run.environment },
);
@@ -312,8 +312,8 @@ export class DeployExecutionService {
'jenkins-build',
nextStatus,
queueItem.buildNumber
? `Jenkins build #${queueItem.buildNumber} is running.`
: `Jenkins queue ${queueItem.queueId} accepted.`,
? `Jenkins 构建 #${queueItem.buildNumber} 正在运行。`
: `Jenkins 队列 ${queueItem.queueId} 已受理。`,
);
await this.auditService.record({
@@ -414,7 +414,7 @@ export class DeployExecutionService {
jobPath: string,
): Promise<DeployRunSummary> {
const logExcerpt = await this.readFailureLogExcerpt(run, jobPath);
const failureSummary = `Jenkins build #${run.jenkinsBuildNumber ?? '-'} ${result}`;
const failureSummary = `Jenkins 构建 #${run.jenkinsBuildNumber ?? '-'} ${result}`;
let currentRun =
(await this.deployRunRepository.updateStep(run.id, 'jenkins-build', {
status: 'failed',
@@ -481,7 +481,7 @@ export class DeployExecutionService {
if (!environment) {
throw new AppError(
'VALIDATION_FAILED',
`Project ${project.key} does not expose ${run.environment} environment`,
`项目 ${project.key} 未配置 ${run.environment} 环境`,
400,
{ projectKey: project.key, environment: run.environment },
);
@@ -558,7 +558,7 @@ export class DeployExecutionService {
if (!run) {
throw new AppError(
'RESOURCE_NOT_FOUND',
`Deploy run ${id} not found`,
`发布记录不存在:${id}`,
404,
{
id,
@@ -574,6 +574,6 @@ export class DeployExecutionService {
return redactSensitive({ message: error.message }).message;
}
return 'Unknown deploy execution error';
return '未知发布执行错误';
}
}
+1 -1
View File
@@ -405,7 +405,7 @@ export class DeployRunRepository {
if (!project || !environment) {
throw new Error(
`Project ${input.projectKey} ${input.environment} is not seeded in database`,
`数据库未初始化项目 ${input.projectKey} ${input.environment} 环境`,
);
}
+57 -24
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import {
ApiBody,
ApiCreatedResponse,
@@ -8,62 +8,95 @@ import {
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import {
CreateDeployRunDto,
CreateDeployRunRequestInput,
createDeployRunSchema,
} from './create-deploy-run.dto';
import { AdminOnlyMessage } from '../auth/admin-only-message.decorator';
import { AuthGuard } from '../auth/auth.guard';
import { AuthenticatedUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/current-user.decorator';
import { PasswordChangeGuard } from '../auth/password-change.guard';
import { SuperAdminGuard } from '../auth/super-admin.guard';
import {
CreateDeployRunInput,
DeployRunJenkinsSyncSummary,
DeployRunSummary,
} from './deploy-run.types';
import { DeployRunsService } from './deploy-runs.service';
@ApiTags('deploy-runs')
@ApiTags('发布记录')
@Controller('deploy-runs')
@UseGuards(AuthGuard, PasswordChangeGuard)
export class DeployRunsController {
constructor(private readonly deployRunsService: DeployRunsService) {}
@Get()
@ApiOkResponse({ description: 'Latest deploy runs.' })
async listRuns(): Promise<DeployRunSummary[]> {
return this.deployRunsService.listRuns();
@ApiOkResponse({ description: '查询当前账号可访问项目的发布记录。' })
async listRuns(
@CurrentUser() user: AuthenticatedUser,
): Promise<DeployRunSummary[]> {
return this.deployRunsService.listRuns(user);
}
@Get(':id')
@ApiOkResponse({ description: 'Deploy run detail.' })
async getRun(@Param('id') id: string): Promise<DeployRunSummary> {
return this.deployRunsService.getRun(id);
@ApiOkResponse({ description: '查询发布记录详情。' })
async getRun(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<DeployRunSummary> {
return this.deployRunsService.getRun(id, user);
}
@Post()
@ApiBody({ type: CreateDeployRunDto })
@ApiCreatedResponse({ description: 'Create a deploy run draft.' })
@ApiCreatedResponse({ description: '创建发布单并触发构建。' })
async createRun(
@Body(new ZodValidationPipe(createDeployRunSchema)) body: CreateDeployRunInput,
@CurrentUser() user: AuthenticatedUser,
@Body(new ZodValidationPipe(createDeployRunSchema))
body: CreateDeployRunRequestInput,
): Promise<DeployRunSummary> {
return this.deployRunsService.createRun(body);
return this.deployRunsService.createRun(
{
...body,
operator: user.account,
},
user,
);
}
@Post(':id/cancel')
@ApiOkResponse({ description: 'Cancel a deploy run.' })
async cancelRun(@Param('id') id: string): Promise<DeployRunSummary> {
return this.deployRunsService.cancelRun(id);
@ApiOkResponse({ description: '取消发布单。' })
async cancelRun(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<DeployRunSummary> {
return this.deployRunsService.cancelRun(id, user);
}
@Post(':id/retry')
@ApiCreatedResponse({ description: 'Retry a deploy run.' })
async retryRun(@Param('id') id: string): Promise<DeployRunSummary> {
return this.deployRunsService.retryRun(id);
@ApiCreatedResponse({ description: '重试发布单。' })
async retryRun(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<DeployRunSummary> {
return this.deployRunsService.retryRun(id, user);
}
@Post('sync-jenkins')
@ApiOkResponse({ description: 'Synchronize all queued/running Jenkins runs.' })
async syncJenkinsRuns(): Promise<DeployRunJenkinsSyncSummary> {
return this.deployRunsService.syncJenkinsRuns();
@UseGuards(SuperAdminGuard)
@AdminOnlyMessage('无权限同步全部 Jenkins 状态')
@ApiOkResponse({ description: '同步全部队列中或运行中的 Jenkins 发布状态。' })
async syncJenkinsRuns(
@CurrentUser() user: AuthenticatedUser,
): Promise<DeployRunJenkinsSyncSummary> {
return this.deployRunsService.syncJenkinsRuns(user);
}
@Post(':id/sync-jenkins')
@ApiOkResponse({ description: 'Synchronize Jenkins queue/build state.' })
async syncJenkinsRun(@Param('id') id: string): Promise<DeployRunSummary> {
return this.deployRunsService.syncJenkinsRun(id);
@ApiOkResponse({ description: '同步单个发布单的 Jenkins 队列或构建状态。' })
async syncJenkinsRun(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<DeployRunSummary> {
return this.deployRunsService.syncJenkinsRun(id, user);
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { JenkinsModule } from '../integrations/jenkins/jenkins.module';
import { WeComModule } from '../notifications/wecom/wecom.module';
import { ProjectsModule } from '../projects/projects.module';
@@ -10,7 +11,7 @@ import { DeployRunsService } from './deploy-runs.service';
import { JenkinsSyncSchedulerService } from './jenkins-sync-scheduler.service';
@Module({
imports: [AuditModule, JenkinsModule, ProjectsModule, WeComModule],
imports: [AuditModule, AuthModule, JenkinsModule, ProjectsModule, WeComModule],
controllers: [DeployRunsController],
providers: [
DeployExecutionService,
+43 -23
View File
@@ -1,4 +1,5 @@
import { AuditService } from '../audit/audit.service';
import { AuthService } from '../auth/auth.service';
import { AppError } from '../common/errors/app-error';
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
@@ -58,30 +59,36 @@ describe('DeployRunsService', () => {
findById: jest.fn((id: string) =>
Promise.resolve(runs.find((run) => run.id === id) ?? null),
),
create: jest.fn((input: CreateDeployRunInput) => ({
...baseRun,
id: input.idempotencyKey ?? 'generated_run',
projectKey: input.projectKey,
environment: input.environment,
ref: input.ref,
operator: input.operator,
trigger: input.trigger ?? 'manual',
})),
create: jest.fn((input: CreateDeployRunInput) =>
Promise.resolve({
...baseRun,
id: input.idempotencyKey ?? 'generated_run',
projectKey: input.projectKey,
environment: input.environment,
ref: input.ref,
operator: input.operator,
trigger: input.trigger ?? 'manual',
}),
),
updateRun: jest.fn((id: string, patch: { status?: DeployRunStatus }) =>
runs.some((run) => run.id === id)
? {
...(runs.find((run) => run.id === id) ?? baseRun),
...patch,
}
: null,
Promise.resolve(
runs.some((run) => run.id === id)
? {
...(runs.find((run) => run.id === id) ?? baseRun),
...patch,
}
: null,
),
),
updateStep: jest.fn((id: string) =>
runs.some((run) => run.id === id)
? {
...(runs.find((run) => run.id === id) ?? baseRun),
status: 'canceled' as const,
}
: null,
Promise.resolve(
runs.some((run) => run.id === id)
? {
...(runs.find((run) => run.id === id) ?? baseRun),
status: 'canceled' as const,
}
: null,
),
),
} satisfies Pick<
DeployRunRepository,
@@ -130,10 +137,22 @@ describe('DeployRunsService', () => {
JenkinsClient,
'healthSummary' | 'cancelQueueItem' | 'stopBuild'
>;
const auth = {
listAllowedProjectKeys: jest.fn(() =>
Promise.resolve(runs.map((run) => run.projectKey)),
),
requireProjectPermission: jest.fn(() => Promise.resolve()),
assertSuperAdmin: jest.fn(),
} satisfies Pick<
AuthService,
'listAllowedProjectKeys' | 'requireProjectPermission' | 'assertSuperAdmin'
>;
return {
audit,
auth,
execution,
jenkins,
notification,
projects,
repository,
@@ -144,6 +163,7 @@ describe('DeployRunsService', () => {
notification as unknown as DeployNotificationService,
execution as unknown as DeployExecutionService,
jenkins as unknown as JenkinsClient,
auth as unknown as AuthService,
),
};
}
@@ -222,7 +242,7 @@ describe('DeployRunsService', () => {
expect.objectContaining({
action: 'DEPLOY_REJECTED',
after: expect.objectContaining({
reason: 'Environment is not configured',
reason: '环境未配置',
}),
}),
);
@@ -258,7 +278,7 @@ describe('DeployRunsService', () => {
expect.objectContaining({
action: 'DEPLOY_REJECTED',
after: expect.objectContaining({
reason: 'Ref does not match release policy',
reason: 'Ref 不符合发布策略',
}),
}),
);
+108 -37
View File
@@ -1,4 +1,6 @@
import { Injectable } from '@nestjs/common';
import { AuthService } from '../auth/auth.service';
import { AuthenticatedUser } from '../auth/auth.types';
import { AppError } from '../common/errors/app-error';
import { redactSensitive } from '../common/security/redact-sensitive';
import { AuditService } from '../audit/audit.service';
@@ -30,19 +32,33 @@ export class DeployRunsService {
private readonly deployNotificationService: DeployNotificationService,
private readonly deployExecutionService: DeployExecutionService,
private readonly jenkinsClient: JenkinsClient,
private readonly authService: AuthService,
) {}
async listRuns(): Promise<DeployRunSummary[]> {
return this.deployRunRepository.findMany();
async listRuns(user?: AuthenticatedUser): Promise<DeployRunSummary[]> {
const runs = await this.deployRunRepository.findMany();
if (!user || user.role === 'super_admin') {
return runs;
}
const allowedProjectKeys = new Set(
await this.authService.listAllowedProjectKeys(user),
);
return runs.filter((run) => allowedProjectKeys.has(run.projectKey));
}
async getRun(id: string): Promise<DeployRunSummary> {
async getRun(
id: string,
user?: AuthenticatedUser,
): Promise<DeployRunSummary> {
const run = await this.deployRunRepository.findById(id);
if (!run) {
throw new AppError(
'RESOURCE_NOT_FOUND',
`Deploy run ${id} not found`,
`发布记录不存在:${id}`,
404,
{
id,
@@ -50,19 +66,38 @@ export class DeployRunsService {
);
}
if (user) {
await this.authService.requireProjectPermission(
user,
run.projectKey,
'read',
);
}
return run;
}
async createRun(input: CreateDeployRunInput): Promise<DeployRunSummary> {
async createRun(
input: CreateDeployRunInput,
user?: AuthenticatedUser,
): Promise<DeployRunSummary> {
if (!['test', 'production'].includes(input.environment)) {
throw new AppError(
'VALIDATION_FAILED',
'environment must be test or production',
'发布环境必须为 test production',
400,
{ environment: input.environment },
);
}
if (user) {
await this.authService.requireProjectPermission(
user,
input.projectKey,
'build',
);
}
const project = await this.projectsService.getProject(input.projectKey);
await this.validateReleaseRequest(input, project);
const run = await this.deployRunRepository.create(input);
@@ -83,13 +118,24 @@ export class DeployRunsService {
return this.deployExecutionService.execute(run, project);
}
async cancelRun(id: string): Promise<DeployRunSummary> {
const source = await this.getRun(id);
async cancelRun(
id: string,
user?: AuthenticatedUser,
): Promise<DeployRunSummary> {
const source = await this.getRun(id, user);
if (user) {
await this.authService.requireProjectPermission(
user,
source.projectKey,
'build',
);
}
if (['success', 'failed', 'canceled'].includes(source.status)) {
throw new AppError(
'VALIDATION_FAILED',
`Deploy run ${id} is already ${source.status} and cannot be canceled`,
`发布记录 ${id} 已是终态,不能取消`,
400,
{
id,
@@ -130,27 +176,52 @@ export class DeployRunsService {
return run;
}
async retryRun(id: string): Promise<DeployRunSummary> {
const source = await this.getRun(id);
return this.createRun({
projectKey: source.projectKey,
environment: source.environment,
ref: source.ref,
operator: source.operator,
remark: `Retry of ${source.id}`,
idempotencyKey: `${source.id}:retry:${new Date().toISOString()}`,
trigger: 'retry',
});
async retryRun(
id: string,
user?: AuthenticatedUser,
): Promise<DeployRunSummary> {
const source = await this.getRun(id, user);
return this.createRun(
{
projectKey: source.projectKey,
environment: source.environment,
ref: source.ref,
operator: user?.account ?? source.operator,
remark: `重试发布记录 ${source.id}`,
idempotencyKey: `${source.id}:retry:${new Date().toISOString()}`,
trigger: 'retry',
},
user,
);
}
async syncJenkinsRun(id: string): Promise<DeployRunSummary> {
const run = await this.getRun(id);
async syncJenkinsRun(
id: string,
user?: AuthenticatedUser,
): Promise<DeployRunSummary> {
const run = await this.getRun(id, user);
if (user) {
await this.authService.requireProjectPermission(
user,
run.projectKey,
'build',
);
}
const project = await this.projectsService.getProject(run.projectKey);
return this.deployExecutionService.syncJenkinsRun(run, project);
}
async syncJenkinsRuns(): Promise<DeployRunJenkinsSyncSummary> {
async syncJenkinsRuns(
user?: AuthenticatedUser,
): Promise<DeployRunJenkinsSyncSummary> {
if (user) {
this.authService.assertSuperAdmin(user, '无权限同步全部 Jenkins 状态');
}
const runs = await this.listRuns();
const candidates = runs.filter((run) => this.isJenkinsSyncCandidate(run));
const syncedRuns: DeployRunSummary[] = [];
@@ -228,7 +299,7 @@ export class DeployRunsService {
if (jenkinsHealth.status === 'not_configured') {
return {
requested: false,
reason: jenkinsHealth.message ?? 'Jenkins is not configured',
reason: jenkinsHealth.message ?? 'Jenkins 尚未配置',
};
}
@@ -245,7 +316,7 @@ export class DeployRunsService {
return {
requested: false,
reason: 'Deploy run has no Jenkins queue id or build number',
reason: '发布记录缺少 Jenkins 队列编号或构建编号',
};
}
@@ -260,7 +331,7 @@ export class DeployRunsService {
if (!environment) {
throw new AppError(
'VALIDATION_FAILED',
`Project ${project.key} does not expose ${run.environment} environment`,
`项目 ${project.key} 未配置 ${run.environment} 环境`,
400,
{ projectKey: project.key, environment: run.environment },
);
@@ -273,14 +344,14 @@ export class DeployRunsService {
result: JenkinsCancelResult | { requested: false; reason: string },
): string {
if (!result.requested) {
return `Cancellation recorded locally: ${result.reason}`;
return `已在本地记录取消结果:${result.reason}`;
}
if (result.target === 'build') {
return `Jenkins build #${result.buildNumber ?? '-'} stop requested.`;
return `已请求停止 Jenkins 构建 #${result.buildNumber ?? '-'}`;
}
return `Jenkins queue ${result.queueId ?? '-'} cancel requested.`;
return `已请求取消 Jenkins 队列 ${result.queueId ?? '-'}`;
}
private async validateReleaseRequest(
@@ -288,10 +359,10 @@ export class DeployRunsService {
project: ProjectSummary,
): Promise<ProjectEnvironmentSummary> {
if (project.status !== 'active') {
await this.recordDeployRejected(input, 'Project is archived');
await this.recordDeployRejected(input, '项目已归档');
throw new AppError(
'VALIDATION_FAILED',
`Project ${project.key} is archived and cannot be deployed`,
`项目 ${project.key} 已归档,不能发布`,
400,
{ projectKey: project.key },
);
@@ -302,10 +373,10 @@ export class DeployRunsService {
);
if (!environment) {
await this.recordDeployRejected(input, 'Environment is not configured');
await this.recordDeployRejected(input, '环境未配置');
throw new AppError(
'VALIDATION_FAILED',
`Project ${project.key} does not expose ${input.environment} environment`,
`项目 ${project.key} 未配置 ${input.environment} 环境`,
400,
{ projectKey: project.key, environment: input.environment },
);
@@ -314,11 +385,11 @@ export class DeployRunsService {
if (!new RegExp(environment.refPattern).test(input.ref)) {
await this.recordDeployRejected(
input,
'Ref does not match release policy',
'Ref 不符合发布策略',
);
throw new AppError(
'VALIDATION_FAILED',
`Ref ${input.ref} does not match ${environment.releasePolicy} release policy`,
`Ref ${input.ref} 不符合 ${environment.releasePolicy} 发布策略`,
400,
{
projectKey: project.key,
@@ -352,7 +423,7 @@ export class DeployRunsService {
private toSafeSyncError(error: unknown): string {
const message =
error instanceof Error ? error.message : 'Jenkins sync failed';
error instanceof Error ? error.message : 'Jenkins 同步失败';
return redactSensitive({ message }).message;
}
@@ -364,7 +435,7 @@ export class DeployRunsService {
if (!run) {
throw new AppError(
'RESOURCE_NOT_FOUND',
`Deploy run ${id} not found`,
`发布记录不存在:${id}`,
404,
{
id,
@@ -65,7 +65,7 @@ export class JenkinsSyncSchedulerService
});
} catch (error) {
const message =
error instanceof Error ? error.message : 'Jenkins auto sync failed';
error instanceof Error ? error.message : 'Jenkins 自动同步失败';
await this.auditService.record({
action: 'JENKINS_AUTO_SYNC_TICK_FAILED',
+1 -1
View File
@@ -8,7 +8,7 @@ export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get()
@ApiOkResponse({ description: 'Runtime dependency health summary.' })
@ApiOkResponse({ description: '查询运行时依赖健康状态。' })
async getHealth(): Promise<ReturnType<HealthService['getHealth']>> {
return this.healthService.getHealth();
}
+3 -1
View File
@@ -30,7 +30,9 @@ describe('HealthService', () => {
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
} satisfies Pick<DeployNotificationService, 'healthSummary'>;
const llm = {
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
healthSummary: jest.fn(() =>
Promise.resolve({ status: 'not_configured' as const }),
),
} satisfies Pick<LlmClient, 'healthSummary'>;
const service = new HealthService(
config as unknown as ConfigService<EnvConfig, true>,
+1 -1
View File
@@ -33,7 +33,7 @@ export class HealthService {
jenkins: this.jenkins.healthSummary(),
gitea: this.gitea.healthSummary(),
notification: this.notification.healthSummary(),
llm: this.llm.healthSummary(),
llm: await this.llm.healthSummary(),
};
const hasUnavailable = Object.values(dependencies).some(
(dependency) => dependency.status === 'unavailable',
+2 -2
View File
@@ -13,7 +13,7 @@ export class GiteaClient {
const missing = this.missingConfig();
return missing.length === 0
? { status: 'ok' }
: { status: 'not_configured', message: `Missing ${missing.join(', ')}` };
: { status: 'not_configured', message: `缺少 ${missing.join(', ')}` };
}
async listBranches(owner: string, repo: string): Promise<GiteaBranch[]> {
@@ -76,7 +76,7 @@ export class GiteaClient {
});
if (!response.ok) {
throw new IntegrationRequestError('gitea', 'Gitea request failed', {
throw new IntegrationRequestError('gitea', 'Gitea 请求失败', {
status: response.status,
path,
request: redactSensitive(init),
+2 -2
View File
@@ -24,7 +24,7 @@ export class JenkinsClient {
const missing = this.missingConfig();
return missing.length === 0
? { status: 'ok' }
: { status: 'not_configured', message: `Missing ${missing.join(', ')}` };
: { status: 'not_configured', message: `缺少 ${missing.join(', ')}` };
}
async buildWithParameters(
@@ -204,7 +204,7 @@ export class JenkinsClient {
path: string,
details?: Record<string, unknown>,
): IntegrationRequestError {
return new IntegrationRequestError('jenkins', 'Jenkins request failed', {
return new IntegrationRequestError('jenkins', 'Jenkins 请求失败', {
path,
...details,
});
+2 -2
View File
@@ -22,8 +22,8 @@ async function bootstrap(): Promise<void> {
app.useGlobalInterceptors(new ApiEnvelopeInterceptor());
const swaggerConfig = new DocumentBuilder()
.setTitle('DevOps Platform API')
.setDescription('Jenkins, Gitea, notification, BPMN deploy runs, and agent operation APIs.')
.setTitle('运维平台 API')
.setDescription('提供 JenkinsGitea、通知、发布流程、账号权限和 Agent 运维能力接口。')
.setVersion('0.1.0')
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
+70
View File
@@ -0,0 +1,70 @@
import { ApiProperty } from '@nestjs/swagger';
import { z } from 'zod';
const permissionLevelSchema = z.enum(['none', 'read', 'build']);
export const projectPermissionSchema = z.object({
projectKey: z.string().trim().min(1).max(80),
level: permissionLevelSchema,
});
export const createMemberSchema = z.object({
account: z.string().trim().min(1).max(80),
displayName: z.string().trim().min(1).max(120),
permissions: z.array(projectPermissionSchema).optional().default([]),
});
export type CreateMemberDtoInput = z.infer<typeof createMemberSchema>;
export class ProjectPermissionDto {
@ApiProperty({ example: 'devops-platform-api', description: '项目标识' })
projectKey!: string;
@ApiProperty({
enum: ['none', 'read', 'build'],
description: '项目权限:none 无权限,read 只读,build 构建',
})
level!: 'none' | 'read' | 'build';
}
export class CreateMemberDto {
@ApiProperty({ example: 'member01', description: '成员账号' })
account!: string;
@ApiProperty({ example: '成员一', description: '成员名称' })
displayName!: string;
@ApiProperty({
type: [ProjectPermissionDto],
required: false,
description: '成员项目权限,未传项目默认为无权限',
})
permissions?: ProjectPermissionDto[];
}
export const updateMemberPermissionsSchema = z.object({
permissions: z.array(projectPermissionSchema).default([]),
});
export type UpdateMemberPermissionsInput = z.infer<
typeof updateMemberPermissionsSchema
>;
export class UpdateMemberPermissionsDto {
@ApiProperty({ type: [ProjectPermissionDto], description: '完整项目权限列表' })
permissions!: ProjectPermissionDto[];
}
export const updateMemberStatusSchema = z.object({
status: z.enum(['active', 'disabled']),
});
export type UpdateMemberStatusInput = z.infer<typeof updateMemberStatusSchema>;
export class UpdateMemberStatusDto {
@ApiProperty({
enum: ['active', 'disabled'],
description: '成员状态:active 启用,disabled 禁用',
})
status!: 'active' | 'disabled';
}
+25
View File
@@ -0,0 +1,25 @@
import { AuthRole, AuthStatus, ProjectPermissionLevel } from '../auth/auth.types';
export type MemberProjectPermissionSummary = {
projectId: string;
projectKey: string;
projectName: string;
level: ProjectPermissionLevel;
levelName: string;
};
export type MemberSummary = {
id: string;
account: string;
displayName: string;
role: AuthRole;
roleName: string;
status: AuthStatus;
statusName: string;
mustChangePassword: boolean;
hasPendingPasswordResetRequest: boolean;
lastLoginAt?: string;
createdAt: string;
updatedAt: string;
projectPermissions: MemberProjectPermissionSummary[];
};
+105
View File
@@ -0,0 +1,105 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBody,
ApiCreatedResponse,
ApiOkResponse,
ApiTags,
} from '@nestjs/swagger';
import { AdminOnlyMessage } from '../auth/admin-only-message.decorator';
import { AuthGuard } from '../auth/auth.guard';
import { AuthenticatedUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/current-user.decorator';
import { PasswordChangeGuard } from '../auth/password-change.guard';
import { SuperAdminGuard } from '../auth/super-admin.guard';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import {
CreateMemberDto,
CreateMemberDtoInput,
UpdateMemberPermissionsDto,
UpdateMemberPermissionsInput,
UpdateMemberStatusDto,
UpdateMemberStatusInput,
createMemberSchema,
updateMemberPermissionsSchema,
updateMemberStatusSchema,
} from './member.dto';
import { MemberSummary } from './member.types';
import { MembersService } from './members.service';
@ApiTags('成员管理')
@Controller('members')
@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard)
@AdminOnlyMessage('无权限访问成员管理')
export class MembersController {
constructor(private readonly membersService: MembersService) {}
@Get()
@ApiOkResponse({ description: '查询成员列表和项目权限。' })
async listMembers(): Promise<MemberSummary[]> {
return this.membersService.listMembers();
}
@Post()
@ApiBody({ type: CreateMemberDto })
@ApiCreatedResponse({ description: '创建普通成员,初始密码固定为 111111。' })
async createMember(
@CurrentUser() user: AuthenticatedUser,
@Body(new ZodValidationPipe(createMemberSchema))
body: CreateMemberDtoInput,
): Promise<MemberSummary> {
return this.membersService.createMember(body, user);
}
@Patch(':id/permissions')
@ApiBody({ type: UpdateMemberPermissionsDto })
@ApiOkResponse({ description: '更新普通成员的项目权限。' })
async updatePermissions(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
@Body(new ZodValidationPipe(updateMemberPermissionsSchema))
body: UpdateMemberPermissionsInput,
): Promise<MemberSummary> {
return this.membersService.updatePermissions(id, body, user);
}
@Post(':id/reset-password')
@ApiOkResponse({
description: '将普通成员密码重置为 111111,并要求下次登录改密。',
})
async resetPassword(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<MemberSummary> {
return this.membersService.resetPassword(id, user);
}
@Patch(':id/status')
@ApiBody({ type: UpdateMemberStatusDto })
@ApiOkResponse({ description: '启用或禁用普通成员账号。' })
async updateStatus(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
@Body(new ZodValidationPipe(updateMemberStatusSchema))
body: UpdateMemberStatusInput,
): Promise<MemberSummary> {
return this.membersService.updateStatus(id, body, user);
}
@Delete(':id')
@ApiOkResponse({ description: '删除普通成员账号。' })
async deleteMember(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<{ message: string }> {
return this.membersService.deleteMember(id, user);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { MessagesModule } from '../messages/messages.module';
import { ProjectsModule } from '../projects/projects.module';
import { MembersController } from './members.controller';
import { MembersService } from './members.service';
@Module({
imports: [AuditModule, AuthModule, MessagesModule, ProjectsModule],
controllers: [MembersController],
providers: [MembersService],
exports: [MembersService],
})
export class MembersModule {}
+288
View File
@@ -0,0 +1,288 @@
import { Injectable } from '@nestjs/common';
import { AuditService } from '../audit/audit.service';
import { MEMBER_INITIAL_PASSWORD, SUPER_ADMIN_SEED } from '../auth/auth.constants';
import {
AuthenticatedUser,
ProjectPermissionInput,
ProjectPermissionLevel,
UserRecord,
} from '../auth/auth.types';
import { PasswordService } from '../auth/password.service';
import { UserRepository } from '../auth/user.repository';
import { AppError } from '../common/errors/app-error';
import { MessagesService } from '../messages/messages.service';
import { ProjectRepository } from '../projects/project.repository';
import {
CreateMemberDtoInput,
UpdateMemberPermissionsInput,
UpdateMemberStatusInput,
} from './member.dto';
import {
MemberProjectPermissionSummary,
MemberSummary,
} from './member.types';
@Injectable()
export class MembersService {
constructor(
private readonly userRepository: UserRepository,
private readonly passwordService: PasswordService,
private readonly messagesService: MessagesService,
private readonly projectRepository: ProjectRepository,
private readonly auditService: AuditService,
) {}
async listMembers(): Promise<MemberSummary[]> {
const users = await this.userRepository.listUsers();
return Promise.all(users.map((user) => this.toMemberSummary(user)));
}
async createMember(
input: CreateMemberDtoInput,
operator: AuthenticatedUser,
): Promise<MemberSummary> {
const account = input.account.trim();
if (account === SUPER_ADMIN_SEED.account) {
throw new AppError(
'CONFLICT',
'该账号为内置超级管理员账号,不能创建为普通成员',
409,
);
}
const existing = await this.userRepository.findByAccount(account);
if (existing) {
throw new AppError('CONFLICT', '成员账号已存在', 409, { account });
}
const passwordHash = await this.passwordService.hashPassword(
MEMBER_INITIAL_PASSWORD,
);
const user = await this.userRepository.createUser({
account,
displayName: input.displayName,
passwordHash,
role: 'member',
mustChangePassword: true,
createdById: operator.id,
});
const updatedUser = await this.userRepository.replaceProjectPermissions(
user.id,
input.permissions ?? [],
);
await this.auditService.record({
action: 'MEMBER_CREATED',
resourceType: 'user',
resourceId: updatedUser.id,
actorId: operator.id,
actorName: operator.account,
after: {
account: updatedUser.account,
permissions: this.safePermissionDigest(input.permissions ?? []),
mustChangePassword: true,
},
});
return this.toMemberSummary(updatedUser);
}
async updatePermissions(
memberId: string,
input: UpdateMemberPermissionsInput,
operator: AuthenticatedUser,
): Promise<MemberSummary> {
const user = await this.requireMutableMember(memberId);
const updatedUser = await this.userRepository.replaceProjectPermissions(
user.id,
input.permissions,
);
await this.auditService.record({
action: 'MEMBER_PERMISSIONS_UPDATED',
resourceType: 'user',
resourceId: updatedUser.id,
actorId: operator.id,
actorName: operator.account,
before: {
permissions: this.safePermissionDigest(user.projectPermissions),
},
after: {
permissions: this.safePermissionDigest(input.permissions),
},
});
return this.toMemberSummary(updatedUser);
}
async resetPassword(
memberId: string,
operator: AuthenticatedUser,
): Promise<MemberSummary> {
const user = await this.requireMutableMember(memberId);
const passwordHash = await this.passwordService.hashPassword(
MEMBER_INITIAL_PASSWORD,
);
const updatedUser = await this.userRepository.updatePassword(
user.id,
passwordHash,
true,
);
await this.messagesService.markPasswordResetHandledForRequester(
user.id,
operator,
);
await this.auditService.record({
action: 'MEMBER_PASSWORD_RESET',
resourceType: 'user',
resourceId: user.id,
actorId: operator.id,
actorName: operator.account,
after: {
resetToInitialPassword: true,
mustChangePassword: true,
},
});
return this.toMemberSummary(updatedUser);
}
async updateStatus(
memberId: string,
input: UpdateMemberStatusInput,
operator: AuthenticatedUser,
): Promise<MemberSummary> {
const user = await this.requireMutableMember(memberId);
const updatedUser = await this.userRepository.setStatus(
user.id,
input.status,
);
await this.auditService.record({
action: 'MEMBER_STATUS_UPDATED',
resourceType: 'user',
resourceId: user.id,
actorId: operator.id,
actorName: operator.account,
before: {
status: user.status,
},
after: {
status: updatedUser.status,
},
});
return this.toMemberSummary(updatedUser);
}
async deleteMember(
memberId: string,
operator: AuthenticatedUser,
): Promise<{ message: string }> {
const user = await this.requireMutableMember(memberId);
await this.userRepository.deleteUser(user.id);
await this.auditService.record({
action: 'MEMBER_DELETED',
resourceType: 'user',
resourceId: user.id,
actorId: operator.id,
actorName: operator.account,
before: {
account: user.account,
},
});
return {
message: '成员已删除',
};
}
private async requireMutableMember(memberId: string): Promise<UserRecord> {
const user = await this.userRepository.findById(memberId);
if (!user) {
throw new AppError('RESOURCE_NOT_FOUND', '成员不存在', 404, { memberId });
}
if (user.role === 'super_admin') {
throw new AppError(
'FORBIDDEN',
'超级管理员不可删除、不可禁用、不可降级、不可重置为普通成员初始密码',
403,
);
}
return user;
}
private async toMemberSummary(user: UserRecord): Promise<MemberSummary> {
return {
id: user.id,
account: user.account,
displayName: user.displayName,
role: user.role,
roleName: user.role === 'super_admin' ? '超级管理员' : '普通成员',
status: user.status,
statusName: user.status === 'active' ? '启用' : '禁用',
mustChangePassword: user.mustChangePassword,
hasPendingPasswordResetRequest:
await this.messagesService.hasPendingPasswordResetRequest(user.id),
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
projectPermissions: await this.resolveProjectPermissions(user),
};
}
private async resolveProjectPermissions(
user: UserRecord,
): Promise<MemberProjectPermissionSummary[]> {
const projects = await this.projectRepository.findMany();
const permissionByProject = new Map(
user.projectPermissions.map((permission) => [
permission.projectKey,
permission.level,
]),
);
return projects.map((project) => {
const level =
user.role === 'super_admin'
? 'build'
: (permissionByProject.get(project.key) ?? 'none');
return {
projectId: project.id,
projectKey: project.key,
projectName: project.name,
level,
levelName: this.permissionLevelName(level),
};
});
}
private permissionLevelName(level: ProjectPermissionLevel): string {
if (level === 'build') {
return '构建权限';
}
if (level === 'read') {
return '只读权限';
}
return '无权限';
}
private safePermissionDigest(
permissions: Array<ProjectPermissionInput | { projectKey: string; level: string }>,
): Array<{ projectKey: string; level: string }> {
return permissions.map((permission) => ({
projectKey: permission.projectKey,
level: permission.level,
}));
}
}
+230
View File
@@ -0,0 +1,230 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
PlatformMessage,
PlatformMessageStatus as PrismaMessageStatus,
PlatformMessageType as PrismaMessageType,
Prisma,
} from '@prisma/client';
import { EnvConfig } from '../config/env.schema';
import { PrismaService } from '../prisma/prisma.service';
import { AuthenticatedUser, UserRecord } from '../auth/auth.types';
import { PlatformMessageSummary } from './message.types';
@Injectable()
export class MessageRepository {
private readonly memoryMessages: PlatformMessageSummary[] = [];
constructor(
private readonly config: ConfigService<EnvConfig, true>,
private readonly prisma: PrismaService,
) {}
async listMessages(): Promise<PlatformMessageSummary[]> {
if (!this.useDatabase()) {
return [...this.memoryMessages].sort((left, right) =>
right.createdAt.localeCompare(left.createdAt),
);
}
const messages = await this.prisma.platformMessage.findMany({
orderBy: {
createdAt: 'desc',
},
take: 100,
});
return messages.map((message) => this.toSummary(message));
}
async countPendingPasswordResetRequests(userId: string): Promise<number> {
if (!this.useDatabase()) {
return this.memoryMessages.filter(
(message) =>
message.requesterId === userId &&
message.type === 'password_reset_request' &&
message.status === 'pending',
).length;
}
return this.prisma.platformMessage.count({
where: {
requesterId: userId,
type: PrismaMessageType.PASSWORD_RESET_REQUEST,
status: PrismaMessageStatus.PENDING,
},
});
}
async createOrReusePasswordResetRequest(
requester: UserRecord,
): Promise<PlatformMessageSummary> {
const content = `账号 ${requester.account} 的成员请求重置密码,请及时处理。`;
if (!this.useDatabase()) {
const existing = this.memoryMessages.find(
(message) =>
message.requesterId === requester.id &&
message.type === 'password_reset_request' &&
message.status === 'pending',
);
if (existing) {
return existing;
}
const now = new Date().toISOString();
const message: PlatformMessageSummary = {
id: `message_${Date.now()}_${this.memoryMessages.length + 1}`,
type: 'password_reset_request',
status: 'pending',
title: '成员密码重置请求',
content,
requesterId: requester.id,
requesterAccount: requester.account,
targetResourceType: 'member',
targetResourceId: requester.id,
targetUrl: `/members?account=${encodeURIComponent(requester.account)}`,
createdAt: now,
updatedAt: now,
};
this.memoryMessages.unshift(message);
return message;
}
const existing = await this.prisma.platformMessage.findFirst({
where: {
requesterId: requester.id,
type: PrismaMessageType.PASSWORD_RESET_REQUEST,
status: PrismaMessageStatus.PENDING,
},
orderBy: {
createdAt: 'desc',
},
});
if (existing) {
return this.toSummary(existing);
}
const message = await this.prisma.platformMessage.create({
data: {
type: PrismaMessageType.PASSWORD_RESET_REQUEST,
status: PrismaMessageStatus.PENDING,
title: '成员密码重置请求',
content,
requesterId: requester.id,
requesterAccount: requester.account,
targetResourceType: 'member',
targetResourceId: requester.id,
metadata: {
targetUrl: `/members?account=${encodeURIComponent(requester.account)}`,
},
},
});
return this.toSummary(message);
}
async markPasswordResetHandledForRequester(
requesterId: string,
handler: AuthenticatedUser,
): Promise<void> {
if (!this.useDatabase()) {
const now = new Date().toISOString();
for (const message of this.memoryMessages) {
if (
message.requesterId === requesterId &&
message.type === 'password_reset_request' &&
message.status === 'pending'
) {
message.status = 'handled';
message.handledById = handler.id;
message.handledAt = now;
message.updatedAt = now;
}
}
return;
}
await this.prisma.platformMessage.updateMany({
where: {
requesterId,
type: PrismaMessageType.PASSWORD_RESET_REQUEST,
status: PrismaMessageStatus.PENDING,
},
data: {
status: PrismaMessageStatus.HANDLED,
handledById: handler.id,
handledAt: new Date(),
},
});
}
async markHandled(messageId: string, handler: AuthenticatedUser): Promise<void> {
if (!this.useDatabase()) {
const message = this.memoryMessages.find((item) => item.id === messageId);
if (message) {
const now = new Date().toISOString();
message.status = 'handled';
message.handledById = handler.id;
message.handledAt = now;
message.updatedAt = now;
}
return;
}
await this.prisma.platformMessage.update({
where: { id: messageId },
data: {
status: PrismaMessageStatus.HANDLED,
handledById: handler.id,
handledAt: new Date(),
},
});
}
private toSummary(message: PlatformMessage): PlatformMessageSummary {
const metadata = this.objectMetadata(message.metadata);
const targetUrl =
typeof metadata.targetUrl === 'string' ? metadata.targetUrl : undefined;
return {
id: message.id,
type: 'password_reset_request',
status:
message.status === PrismaMessageStatus.PENDING ? 'pending' : 'handled',
title: message.title,
content: message.content,
requesterId: message.requesterId ?? undefined,
requesterAccount: message.requesterAccount,
targetResourceType: message.targetResourceType ?? undefined,
targetResourceId: message.targetResourceId ?? undefined,
targetUrl,
handledById: message.handledById ?? undefined,
handledAt: message.handledAt?.toISOString(),
createdAt: message.createdAt.toISOString(),
updatedAt: message.updatedAt.toISOString(),
};
}
private objectMetadata(value: Prisma.JsonValue): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value;
}
private useDatabase(): boolean {
return (
this.config.get('USE_DATABASE_READS', { infer: true }) &&
Boolean(this.config.get('DATABASE_URL', { infer: true }))
);
}
}
+20
View File
@@ -0,0 +1,20 @@
export type PlatformMessageKind = 'password_reset_request';
export type PlatformMessageState = 'pending' | 'handled';
export type PlatformMessageSummary = {
id: string;
type: PlatformMessageKind;
status: PlatformMessageState;
title: string;
content: string;
requesterId?: string;
requesterAccount: string;
targetResourceType?: string;
targetResourceId?: string;
targetUrl?: string;
handledById?: string;
handledAt?: string;
createdAt: string;
updatedAt: string;
};
+37
View File
@@ -0,0 +1,37 @@
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AdminOnlyMessage } from '../auth/admin-only-message.decorator';
import { AuthGuard } from '../auth/auth.guard';
import { AuthenticatedUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/current-user.decorator';
import { PasswordChangeGuard } from '../auth/password-change.guard';
import { SuperAdminGuard } from '../auth/super-admin.guard';
import { PlatformMessageSummary } from './message.types';
import { MessagesService } from './messages.service';
@ApiTags('消息通知')
@Controller('messages')
@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard)
@AdminOnlyMessage('无权限访问消息通知')
export class MessagesController {
constructor(private readonly messagesService: MessagesService) {}
@Get()
@ApiOkResponse({ description: '查询超级管理员消息列表。' })
async listMessages(): Promise<PlatformMessageSummary[]> {
return this.messagesService.listMessages();
}
@Post(':id/handled')
@ApiOkResponse({ description: '将消息标记为已处理。' })
async markHandled(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<{ message: string }> {
await this.messagesService.markHandled(id, user);
return {
message: '消息已标记为已处理',
};
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module, forwardRef } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { MessageRepository } from './message.repository';
import { MessagesController } from './messages.controller';
import { MessagesService } from './messages.service';
@Module({
imports: [forwardRef(() => AuthModule)],
controllers: [MessagesController],
providers: [MessageRepository, MessagesService],
exports: [MessageRepository, MessagesService],
})
export class MessagesModule {}
+42
View File
@@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { AuthenticatedUser, UserRecord } from '../auth/auth.types';
import { MessageRepository } from './message.repository';
import { PlatformMessageSummary } from './message.types';
@Injectable()
export class MessagesService {
constructor(private readonly messageRepository: MessageRepository) {}
async listMessages(): Promise<PlatformMessageSummary[]> {
return this.messageRepository.listMessages();
}
async hasPendingPasswordResetRequest(userId: string): Promise<boolean> {
const count =
await this.messageRepository.countPendingPasswordResetRequests(userId);
return count > 0;
}
async createPasswordResetRequest(
requester: UserRecord,
): Promise<PlatformMessageSummary> {
return this.messageRepository.createOrReusePasswordResetRequest(requester);
}
async markPasswordResetHandledForRequester(
requesterId: string,
handler: AuthenticatedUser,
): Promise<void> {
await this.messageRepository.markPasswordResetHandledForRequester(
requesterId,
handler,
);
}
async markHandled(
messageId: string,
handler: AuthenticatedUser,
): Promise<void> {
await this.messageRepository.markHandled(messageId, handler);
}
}
@@ -43,7 +43,7 @@ export class DeployNotificationService {
if (!provider) {
return {
status: 'not_configured',
message: 'Missing NOTIFICATION_PROVIDER',
message: '缺少 NOTIFICATION_PROVIDER',
};
}
@@ -63,7 +63,7 @@ export class DeployNotificationService {
: {
status: 'not_configured',
provider,
message: `Missing ${webhookKey}`,
message: `缺少 ${webhookKey}`,
};
}
@@ -77,7 +77,7 @@ export class DeployNotificationService {
return {
status: 'skipped',
channel: health.provider ?? 'none',
reason: health.message ?? 'Notification provider is not configured',
reason: health.message ?? '通知平台尚未配置',
};
}
@@ -85,7 +85,7 @@ export class DeployNotificationService {
return {
status: 'failed',
channel: health.provider ?? 'none',
reason: health.message ?? 'Notification provider is unavailable',
reason: health.message ?? '通知平台不可用',
};
}
@@ -100,7 +100,7 @@ export class DeployNotificationService {
reason:
error instanceof Error
? redactSensitive({ message: error.message }).message
: 'Unknown notification error',
: '未知通知错误',
};
}
}
@@ -196,7 +196,7 @@ export class DeployNotificationService {
});
if (!response.ok) {
throw new Error(`${provider} webhook request failed: ${response.status}`);
throw new Error(`${provider} webhook 请求失败:${response.status}`);
}
}
+2 -2
View File
@@ -12,7 +12,7 @@ export class WeComClient {
healthSummary(): { status: 'ok' | 'not_configured'; message?: string } {
return this.config.get('WECOM_WEBHOOK_URL', { infer: true })
? { status: 'ok' }
: { status: 'not_configured', message: 'Missing WECOM_WEBHOOK_URL' };
: { status: 'not_configured', message: '缺少 WECOM_WEBHOOK_URL' };
}
async sendMarkdown(message: WeComMarkdownMessage): Promise<WeComSendResult> {
@@ -32,7 +32,7 @@ export class WeComClient {
});
if (!response.ok) {
throw new IntegrationRequestError('wecom', 'WeCom webhook request failed', {
throw new IntegrationRequestError('wecom', '企微 webhook 请求失败', {
status: response.status,
message: redactSensitive(message),
});
+1 -1
View File
@@ -44,7 +44,7 @@ export class PrismaService
} catch (error) {
return {
status: 'unavailable',
message: error instanceof Error ? error.message : 'Unknown database error',
message: error instanceof Error ? error.message : '未知数据库错误',
};
}
}
@@ -1,17 +1,20 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from '../auth/auth.guard';
import { PasswordChangeGuard } from '../auth/password-change.guard';
import { ProcessDefinitionSummary } from './process-definition.types';
import { ProcessDefinitionsService } from './process-definitions.service';
@ApiTags('process-definitions')
@ApiTags('流程定义')
@Controller('process-definitions')
@UseGuards(AuthGuard, PasswordChangeGuard)
export class ProcessDefinitionsController {
constructor(
private readonly processDefinitionsService: ProcessDefinitionsService,
) {}
@Get('release')
@ApiOkResponse({ description: 'BPMN process definition for deploy runs.' })
@ApiOkResponse({ description: '查询发布流程 BPMN 定义。' })
getReleaseProcess(): ProcessDefinitionSummary {
return this.processDefinitionsService.getReleaseProcess();
}
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ProcessDefinitionsController } from './process-definitions.controller';
import { ProcessDefinitionsService } from './process-definitions.service';
@Module({
imports: [AuthModule],
controllers: [ProcessDefinitionsController],
providers: [ProcessDefinitionsService],
exports: [ProcessDefinitionsService],
+24 -11
View File
@@ -1,28 +1,41 @@
import { Controller, Get, Param } from '@nestjs/common';
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from '../auth/auth.guard';
import { AuthenticatedUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/current-user.decorator';
import { PasswordChangeGuard } from '../auth/password-change.guard';
import { ProjectRefSummary, ProjectSummary } from './project.types';
import { ProjectsService } from './projects.service';
@ApiTags('projects')
@ApiTags('项目')
@Controller('projects')
@UseGuards(AuthGuard, PasswordChangeGuard)
export class ProjectsController {
constructor(private readonly projectsService: ProjectsService) {}
@Get()
@ApiOkResponse({ description: 'Configured project list.' })
async listProjects(): Promise<ProjectSummary[]> {
return this.projectsService.listProjects();
@ApiOkResponse({ description: '查询当前账号可访问的项目列表。' })
async listProjects(
@CurrentUser() user: AuthenticatedUser,
): Promise<ProjectSummary[]> {
return this.projectsService.listProjects(user);
}
@Get(':key')
@ApiOkResponse({ description: 'Project detail by key.' })
async getProject(@Param('key') key: string): Promise<ProjectSummary> {
return this.projectsService.getProject(key);
@ApiOkResponse({ description: '查询项目详情。' })
async getProject(
@Param('key') key: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<ProjectSummary> {
return this.projectsService.getProject(key, user);
}
@Get(':key/refs')
@ApiOkResponse({ description: 'Project branches and tags.' })
async listProjectRefs(@Param('key') key: string): Promise<ProjectRefSummary> {
return this.projectsService.listProjectRefs(key);
@ApiOkResponse({ description: '查询项目分支和标签。' })
async listProjectRefs(
@Param('key') key: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<ProjectRefSummary> {
return this.projectsService.listProjectRefs(key, user);
}
}
+2 -1
View File
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { GiteaModule } from '../integrations/gitea/gitea.module';
import { ProjectRepository } from './project.repository';
import { ProjectsController } from './projects.controller';
import { ProjectsService } from './projects.service';
@Module({
imports: [GiteaModule],
imports: [AuthModule, GiteaModule],
controllers: [ProjectsController],
providers: [ProjectRepository, ProjectsService],
exports: [ProjectRepository, ProjectsService],
+10
View File
@@ -1,3 +1,4 @@
import { AuthService } from '../auth/auth.service';
import { AppError } from '../common/errors/app-error';
import { GiteaClient } from '../integrations/gitea/gitea.client';
import { ProjectRepository } from './project.repository';
@@ -37,13 +38,22 @@ describe('ProjectsService', () => {
Promise.resolve(options?.tags ?? [{ name: 'v1.0.0', commitSha: 'def' }]),
),
} satisfies Pick<GiteaClient, 'healthSummary' | 'listBranches' | 'listTags'>;
const auth = {
listAllowedProjectKeys: jest.fn(() => Promise.resolve([project.key])),
requireProjectPermission: jest.fn(() => Promise.resolve()),
} satisfies Pick<
AuthService,
'listAllowedProjectKeys' | 'requireProjectPermission'
>;
return {
auth,
gitea,
repository,
service: new ProjectsService(
repository as unknown as ProjectRepository,
gitea as unknown as GiteaClient,
auth as unknown as AuthService,
),
};
}
+29 -6
View File
@@ -1,4 +1,6 @@
import { Injectable } from '@nestjs/common';
import { AuthService } from '../auth/auth.service';
import { AuthenticatedUser } from '../auth/auth.types';
import { AppError } from '../common/errors/app-error';
import { GiteaClient } from '../integrations/gitea/gitea.client';
import { ProjectRepository } from './project.repository';
@@ -9,26 +11,47 @@ export class ProjectsService {
constructor(
private readonly projectRepository: ProjectRepository,
private readonly giteaClient: GiteaClient,
private readonly authService: AuthService,
) {}
async listProjects(): Promise<ProjectSummary[]> {
return this.projectRepository.findMany();
async listProjects(user?: AuthenticatedUser): Promise<ProjectSummary[]> {
const projects = await this.projectRepository.findMany();
if (!user || user.role === 'super_admin') {
return projects;
}
const allowedProjectKeys = new Set(
await this.authService.listAllowedProjectKeys(user),
);
return projects.filter((project) => allowedProjectKeys.has(project.key));
}
async getProject(key: string): Promise<ProjectSummary> {
async getProject(
key: string,
user?: AuthenticatedUser,
): Promise<ProjectSummary> {
const project = await this.projectRepository.findByKey(key);
if (!project) {
throw new AppError('RESOURCE_NOT_FOUND', `Project ${key} not found`, 404, {
throw new AppError('RESOURCE_NOT_FOUND', `项目不存在:${key}`, 404, {
key,
});
}
if (user) {
await this.authService.requireProjectPermission(user, key, 'read');
}
return project;
}
async listProjectRefs(key: string): Promise<ProjectRefSummary> {
const project = await this.getProject(key);
async listProjectRefs(
key: string,
user?: AuthenticatedUser,
): Promise<ProjectRefSummary> {
const project = await this.getProject(key, user);
const repo = this.parseRepository(project);
if (repo && this.giteaClient.healthSummary().status === 'ok') {
+9 -4
View File
@@ -1,17 +1,22 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AdminOnlyMessage } from '../auth/admin-only-message.decorator';
import { AuthGuard } from '../auth/auth.guard';
import { PasswordChangeGuard } from '../auth/password-change.guard';
import { SuperAdminGuard } from '../auth/super-admin.guard';
import { SettingsService } from './settings.service';
import { IntegrationConfigStatusResponse } from './settings.types';
@ApiTags('settings')
@ApiTags('系统设置')
@Controller('settings')
@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard)
@AdminOnlyMessage('无权限访问系统设置')
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
@Get('integration-config')
@ApiOkResponse({
description:
'Integration configuration status with secret values redacted server-side.',
description: '查询集成配置状态,所有密钥值均在服务端脱敏。',
})
getIntegrationConfig(): IntegrationConfigStatusResponse {
return this.settingsService.getIntegrationConfigStatus();
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';
@Module({
imports: [AuthModule],
controllers: [SettingsController],
providers: [SettingsService],
})
@@ -15,7 +15,7 @@ export class GiteaWebhookController {
constructor(private readonly giteaWebhookService: GiteaWebhookService) {}
@Post()
@ApiCreatedResponse({ description: 'Receive a Gitea webhook event.' })
@ApiCreatedResponse({ description: '接收 Gitea webhook 事件。' })
async receiveWebhook(
@Headers('x-gitea-event') event: string | undefined,
@Headers('x-gitea-delivery') delivery: string | undefined,
@@ -116,7 +116,7 @@ describe('GiteaWebhookService', () => {
});
expect(result.accepted).toBe(false);
expect(result.reason).toContain('Only branch push');
expect(result.reason).toContain('只有分支 push');
expect(deployRuns.createRun).not.toHaveBeenCalled();
});
+7 -7
View File
@@ -34,7 +34,7 @@ export class GiteaWebhookService {
if (input.headers.event !== 'push') {
return this.ignore(
input.headers,
'Only push events can trigger test deploys',
'只有 push 事件可以触发测试环境发布',
);
}
@@ -43,7 +43,7 @@ export class GiteaWebhookService {
if (!payload.ref.startsWith('refs/heads/')) {
return this.ignore(
input.headers,
'Only branch push events trigger test deploys',
'只有分支 push 事件可以触发测试环境发布',
);
}
@@ -70,7 +70,7 @@ export class GiteaWebhookService {
if (!project) {
return this.ignore(
input.headers,
'Repository is not managed by this platform',
'仓库未纳入当前平台管理',
);
}
@@ -83,7 +83,7 @@ export class GiteaWebhookService {
if (!allowedBranches.has(branch)) {
return this.ignore(
input.headers,
`Branch ${branch} is not configured for automatic test deploy`,
`分支 ${branch} 未配置自动测试环境发布`,
project.key,
branch,
);
@@ -146,7 +146,7 @@ export class GiteaWebhookService {
) {
throw new AppError(
'VALIDATION_FAILED',
'Invalid Gitea webhook signature',
'Gitea webhook 签名无效',
401,
);
}
@@ -156,7 +156,7 @@ export class GiteaWebhookService {
if (!isRecord(payload) || !isRecord(payload.repository)) {
throw new AppError(
'VALIDATION_FAILED',
'Invalid Gitea push payload',
'Gitea push 载荷无效',
400,
);
}
@@ -167,7 +167,7 @@ export class GiteaWebhookService {
if (!ref || !after) {
throw new AppError(
'VALIDATION_FAILED',
'Gitea push payload missing ref or after',
'Gitea push 载荷缺少 ref after',
400,
);
}