feat(平台): 补齐服务端持久化和AI拆解契约

This commit is contained in:
Script Generator
2026-06-25 15:21:32 +08:00
parent 5723356d08
commit 5adc7759ad
73 changed files with 5599 additions and 368 deletions

View File

@@ -1,3 +1,6 @@
# 后端 NestJS 环境变量 — 复制为 .env 后填入真实值
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm
# Anthropic API KeyV3.1 起 AI 拆解 Agent 必需)
# 申请https://console.anthropic.com/
ANTHROPIC_API_KEY=sk-ant-xxx
REDIS_URL=redis://localhost:6379

View File

@@ -0,0 +1,9 @@
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: '.',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
testEnvironment: 'node',
};

View File

@@ -16,6 +16,7 @@
"db:studio": "prisma studio"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.27.0",
"@ftb/shared": "workspace:*",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
@@ -24,6 +25,7 @@
"@prisma/client": "^5.15.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"openai": "^4.104.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0"
},

View File

@@ -167,3 +167,12 @@ model ProjectMember {
@@unique([projectId, userId])
@@map("project_members")
}
model AppData {
key String @id
value Json
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("app_data")
}

View File

@@ -2,9 +2,12 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { ProductModule } from './modules/product/product.module';
import { RequirementModule } from './modules/requirement/requirement.module';
import { AiModule } from './modules/ai/ai.module';
import { ConfigModule } from './modules/config/config.module';
import { DataModule } from './modules/data/data.module';
@Module({
imports: [PrismaModule, ProductModule, RequirementModule],
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, AiModule],
controllers: [],
providers: [],
})

View File

@@ -1,7 +1,18 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { existsSync } from 'fs';
import { resolve } from 'path';
import { AppModule } from './app.module';
// 加载 .envNode v20+ 内置 loadEnvFile
// 优先 cwd/.env其次 dist 上一级(编译运行时 cwd 可能在 dist/
for (const candidate of [resolve(process.cwd(), '.env'), resolve(__dirname, '../../.env'), resolve(__dirname, '../.env')]) {
if (existsSync(candidate) && typeof (process as any).loadEnvFile === 'function') {
(process as any).loadEnvFile(candidate);
break;
}
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api/v1');

View File

@@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import type { AiProvider } from './providers/provider.interface';
import { createProvider } from './providers/factory';
import { AiConfigService } from '../config/ai-config.service';
/**
* AI Gateway — 屏蔽底层 Provider对上层 AiService 提供统一 callTool 接口
* 激活的 Provider 切换 / Key 改变时自动重建客户端
*/
@Injectable()
export class AiGatewayService {
private readonly logger = new Logger(AiGatewayService.name);
private cachedProvider: AiProvider | null = null;
private cachedKey: string = '';
constructor(private readonly config: AiConfigService) {}
async getActiveProvider(): Promise<AiProvider> {
const cfg = await this.config.getActiveProvider();
if (!cfg) {
throw new Error('未配置任何 AI 提供商请在「AI 配置」页面新增并激活一个');
}
const sig = `${cfg.format}:${cfg.baseURL}:${cfg.apiKey}`;
if (!this.cachedProvider || this.cachedKey !== sig) {
this.cachedProvider = createProvider(cfg);
this.cachedKey = sig;
this.logger.log(`Provider 切换到 ${cfg.name} (${cfg.format})`);
}
return this.cachedProvider;
}
async getActiveModel(): Promise<string> {
const cfg = await this.config.getActiveProvider();
if (!cfg) throw new Error('未配置任何 AI 提供商');
return cfg.model;
}
}

View File

@@ -0,0 +1,14 @@
import { Body, Controller, Post } from '@nestjs/common';
import { AiService } from './ai.service';
import { DecomposeDto } from './dto/decompose.dto';
import type { AgentDecomposeResponse, AgentDecomposeError } from '@ftb/shared';
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Post('decompose')
async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
return this.aiService.decompose(dto);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
import { AiGatewayService } from './ai-gateway.service';
import { ConfigModule } from '../config/config.module';
@Module({
imports: [ConfigModule],
controllers: [AiController],
providers: [AiService, AiGatewayService],
exports: [AiService],
})
export class AiModule {}

View File

@@ -0,0 +1,155 @@
import { AiService } from './ai.service';
import type { AiGatewayService } from './ai-gateway.service';
import { DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose';
const missingToolUseMessage =
'Anthropic 未通过 tool_use 返回结果stop_reason=tool_usecontent=text("⚠️ 上游模型未返回任何内容。可能原因:触发了安全策略、上游限流、或模型对当前输入直接结束。")';
describe('AiService', () => {
it('retries once with shorter prototype context when Anthropic returns no tool_use', async () => {
const callTool = jest
.fn()
.mockRejectedValueOnce(new Error(missingToolUseMessage))
.mockResolvedValueOnce({
toolName: 'submit_decompose',
toolInput: {
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
devTaskDrafts: [],
testCaseDrafts: [],
},
inputTokens: 100,
outputTokens: 20,
rawModel: 'claude-test',
});
const gateway = {
getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
getActiveModel: jest.fn().mockResolvedValue('claude-test'),
} as unknown as AiGatewayService;
const service = new AiService(gateway);
(service as any).fetchPrototype = jest.fn().mockResolvedValue(
`${'无关内容'.repeat(5000)} QY0001手机号登录。${'噪声内容'.repeat(5000)}`,
);
const result = await service.decompose({
prototypeUrl: 'https://example.com/prototype',
requirements: [{ id: 'req-1', code: 'REQ001', title: '手机号登录' }],
members: [{ name: '张三', role: 'frontend' }],
versionId: 'version-1',
planId: 'plan-1',
});
expect(result.ok).toBe(true);
expect(callTool).toHaveBeenCalledTimes(2);
const firstPrompt = callTool.mock.calls[0][0].userPrompt;
const retryPrompt = callTool.mock.calls[1][0].userPrompt;
expect(retryPrompt.length).toBeLessThan(firstPrompt.length);
expect(retryPrompt).toContain('重试');
});
it('keeps shrinking prototype context when the first retry still returns no tool_use', async () => {
const callTool = jest
.fn()
.mockRejectedValueOnce(new Error(missingToolUseMessage))
.mockRejectedValueOnce(new Error(missingToolUseMessage))
.mockResolvedValueOnce({
toolName: 'submit_decompose',
toolInput: {
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
devTaskDrafts: [],
testCaseDrafts: [],
},
inputTokens: 100,
outputTokens: 20,
rawModel: 'claude-test',
});
const gateway = {
getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
getActiveModel: jest.fn().mockResolvedValue('claude-test'),
} as unknown as AiGatewayService;
const service = new AiService(gateway);
(service as any).fetchPrototype = jest.fn().mockResolvedValue(
Array.from({ length: 30 }, (_, i) => `QY${String(i + 1).padStart(4, '0')}${'内容'.repeat(300)}`).join('\n'),
);
const result = await service.decompose({
prototypeUrl: 'https://example.com/prototype',
requirements: [{ id: 'req-1', code: 'REQ001', title: '手机号登录' }],
members: [{ name: '张三', role: 'frontend' }],
versionId: 'version-1',
planId: 'plan-1',
});
expect(result.ok).toBe(true);
expect(callTool).toHaveBeenCalledTimes(3);
const firstPrompt = callTool.mock.calls[0][0].userPrompt;
const secondPrompt = callTool.mock.calls[1][0].userPrompt;
const thirdPrompt = callTool.mock.calls[2][0].userPrompt;
expect(secondPrompt.length).toBeLessThan(firstPrompt.length);
expect(thirdPrompt.length).toBeLessThan(secondPrompt.length);
});
it('retries prototype fetch before returning fetch failure', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = jest
.fn()
.mockRejectedValueOnce(new Error('fetch failed'))
.mockResolvedValueOnce({
ok: true,
text: async () => '<html><body>QY0001手机号登录</body></html>',
});
(globalThis as any).fetch = fetchMock;
const callTool = jest.fn().mockResolvedValue({
toolName: 'submit_decompose',
toolInput: {
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
devTaskDrafts: [],
testCaseDrafts: [],
},
inputTokens: 100,
outputTokens: 20,
rawModel: 'claude-test',
});
const gateway = {
getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
getActiveModel: jest.fn().mockResolvedValue('claude-test'),
} as unknown as AiGatewayService;
const service = new AiService(gateway);
try {
const result = await service.decompose({
prototypeUrl: 'https://example.com/prototype',
requirements: [{ id: 'req-1', code: 'REQ001', title: '手机登录' }],
members: [{ name: '张三', role: 'frontend' }],
versionId: 'version-1',
planId: 'plan-1',
});
expect(result.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(callTool).toHaveBeenCalledTimes(1);
} finally {
(globalThis as any).fetch = originalFetch;
}
});
it('requires categoryCode for dev task and test case drafts', () => {
const devRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items.required;
const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required;
expect(devRequired).toContain('categoryCode');
expect(testCaseRequired).toContain('categoryCode');
});
it('requires estimateHours for test case drafts', () => {
const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required;
expect(testCaseRequired).toContain('estimateHours');
});
});

View File

@@ -0,0 +1,223 @@
import { Injectable, Logger } from '@nestjs/common';
import { AiGatewayService } from './ai-gateway.service';
import {
DECOMPOSE_SYSTEM_PROMPT,
DECOMPOSE_TOOL_NAME,
DECOMPOSE_TOOL_DESCRIPTION,
DECOMPOSE_TOOL_INPUT_SCHEMA,
} from './prompts/decompose';
import { buildPrototypeContext } from './prototype-context';
import type {
AgentDecomposeRequest,
AgentDecomposeResponse,
AgentDecomposeError,
AgentDecomposeResult,
} from '@ftb/shared';
const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const;
const PROTOTYPE_FETCH_TIMEOUT_MS = 15000;
const PROTOTYPE_FETCH_ATTEMPTS = 3;
const PROTOTYPE_FETCH_RETRY_DELAY_MS = 500;
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
constructor(private readonly gateway: AiGatewayService) {}
async decompose(req: AgentDecomposeRequest): Promise<AgentDecomposeResponse | AgentDecomposeError> {
const startedAt = Date.now();
if (!req.requirements || req.requirements.length === 0) {
return {
ok: false,
error: '本版本未关联任何需求,无法进行原型拆解',
code: 'EMPTY_REQUIREMENTS',
};
}
let prototypeContent: string;
try {
prototypeContent = await this.fetchPrototype(req.prototypeUrl);
} catch (e: any) {
this.logger.error(`抓取原型失败: ${e.message}`);
return {
ok: false,
error: `无法访问原型链接:${e.message}`,
code: 'PROTOTYPE_FETCH_FAILED',
};
}
let provider;
let model: string;
try {
provider = await this.gateway.getActiveProvider();
model = await this.gateway.getActiveModel();
} catch (e: any) {
return {
ok: false,
error: e?.message || '未配置 AI 提供商',
code: 'NO_PROVIDER',
};
}
let toolResp;
for (let attemptIndex = 0; attemptIndex < DECOMPOSE_CONTEXT_CHAR_STEPS.length; attemptIndex++) {
const contextChars = DECOMPOSE_CONTEXT_CHAR_STEPS[attemptIndex];
const prototypeContext = buildPrototypeContext(prototypeContent, contextChars);
const userPrompt = this.buildUserPrompt(req, prototypeContext.text, prototypeContext, attemptIndex > 0);
try {
toolResp = await provider.callTool({
systemPrompt: DECOMPOSE_SYSTEM_PROMPT,
userPrompt,
tool: {
name: DECOMPOSE_TOOL_NAME,
description: DECOMPOSE_TOOL_DESCRIPTION,
inputSchema: DECOMPOSE_TOOL_INPUT_SCHEMA,
},
forceTool: true,
maxTokens: 16000,
model,
});
break;
} catch (e: any) {
const canRetry = this.shouldRetryWithShortContext(e) && attemptIndex < DECOMPOSE_CONTEXT_CHAR_STEPS.length - 1;
if (canRetry) {
this.logger.warn(
`AI 第 ${attemptIndex + 1} 次调用未返回 tool_use使用 ${DECOMPOSE_CONTEXT_CHAR_STEPS[attemptIndex + 1]} 字符原型上下文重试: ${e.message}`,
);
continue;
}
this.logger.error(`AI 调用失败: ${e.message}`);
return {
ok: false,
error: `AI 服务调用失败:${e.message}`,
code: 'API_ERROR',
};
}
}
if (!toolResp) {
return {
ok: false,
error: 'AI 服务调用失败:未获得工具结果',
code: 'UNKNOWN',
};
}
const result = toolResp.toolInput as AgentDecomposeResult;
if (!result.report || !Array.isArray(result.devTaskDrafts) || !Array.isArray(result.testCaseDrafts)) {
return {
ok: false,
error: 'AI 返回结构不符合预期',
code: 'PARSE_ERROR',
};
}
return {
ok: true,
result,
meta: {
model: toolResp.rawModel || model,
inputTokens: toolResp.inputTokens,
outputTokens: toolResp.outputTokens,
durationMs: Date.now() - startedAt,
},
};
}
private async fetchPrototype(url: string): Promise<string> {
if (!url || !url.startsWith('http')) {
throw new Error('原型链接无效');
}
let lastError: unknown;
for (let attempt = 1; attempt <= PROTOTYPE_FETCH_ATTEMPTS; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), PROTOTYPE_FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const html = await res.text();
return this.htmlToText(html);
} catch (e) {
lastError = e;
if (attempt < PROTOTYPE_FETCH_ATTEMPTS) {
this.logger.warn(`抓取原型失败,准备重试(${attempt}/${PROTOTYPE_FETCH_ATTEMPTS}): ${(e as Error).message}`);
await this.sleep(PROTOTYPE_FETCH_RETRY_DELAY_MS * attempt);
}
} finally {
clearTimeout(timeoutId);
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
private htmlToText(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/\s+/g, ' ')
.trim();
}
private shouldRetryWithShortContext(error: any): boolean {
const msg = error?.message || '';
return (
msg.includes('Anthropic 未通过 tool_use 返回结果') &&
(msg.includes('上游模型未返回任何内容') || msg.includes('stop_reason=tool_use'))
);
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private buildUserPrompt(
req: AgentDecomposeRequest,
prototypeContent: string,
context?: { noteCount: number; truncated: boolean },
isRetry = false,
): string {
const reqList = req.requirements
.map(
(r) =>
`- id: ${r.id}, code: ${r.code}, title: ${r.title}${r.description ? `, desc: ${r.description}` : ''}`,
)
.join('\n');
const memberList = req.members.map((m) => `- ${m.name} (${m.role})`).join('\n');
return `${isRetry ? '## 重试说明\n上一次 AI 服务未返回工具结果,本次已缩短原型上下文。仍然必须只通过 submit_decompose 工具返回结果。\n\n' : ''}## 产品方案原型
URL: ${req.prototypeUrl}
### 原型内容(已提取 QY 批注相关片段)
- 识别到 QY 批注数量:${context?.noteCount ?? 0}
- 内容已截断:${context?.truncated ? '是' : '否'}
${prototypeContent}
## 本版本关联需求
${reqList || '(无)'}
## 版本成员
${memberList || '(无)'}
## 你的任务
按系统提示词的规则,拆解出开发任务草案、测试用例草案、对账报告。
通过 tool 调用 submit_decompose 返回结果。`;
}
}

View File

@@ -0,0 +1,46 @@
import { IsString, IsArray, ValidateNested, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
export class DecomposeReqMemberDto {
@IsString()
name!: string;
@IsString()
role!: string;
}
export class DecomposeReqRequirementDto {
@IsString()
id!: string;
@IsString()
code!: string;
@IsString()
title!: string;
@IsString()
@IsOptional()
description?: string;
}
export class DecomposeDto {
@IsString()
prototypeUrl!: string;
@IsString()
versionId!: string;
@IsString()
planId!: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => DecomposeReqRequirementDto)
requirements!: DecomposeReqRequirementDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => DecomposeReqMemberDto)
members!: DecomposeReqMemberDto[];
}

View File

@@ -0,0 +1,186 @@
/**
* Prototype Decompose Agent — System Prompt + Tool Schema
* 详细规范见 docs/agent-spec.md
*
* 注意:这里的 Tool Schema 是格式无关的 JSON Schema
* 由各 Provider 自己包成 Anthropic 的 input_schema 或 OpenAI 的 parameters
*/
export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品方案拆解助手。
【职责】
输入:原型 HTML/文档内容 + 关联需求列表 + 版本成员清单
输出:开发任务草案 + 测试用例草案 + 对账报告
【硬规则】
1. 引用必须真实
- 引用 requirement.id 必须在输入需求清单中
- 引用 prototype_note 必须是输入原型里真实存在的 QY 编号
- 不得编造
2. 任务来源限定
- 只为以下情况拆任务:
a) 同时被需求和原型 QY 命中
b) 仅需求命中(原型未涉及,按需求文字拆,但工时设小,标记需要后期补充)
- 仅 QY 命中、需求未提的批注,不拆任务,只在 noteOnly 报告里列出
3. 颗粒度(细颗粒)
- 一条 QY 涉及前后端时,前端任务和后端任务必须分开
- 接口、数据库改动、前端 UI、前端交互、表单校验视为独立任务
- 一条 QY 可能产出 3-6 个 DevTask
- 测试用例:每条 QY 至少 1 条功能用例 + 1 条边界用例
4. 任务类型
- 每条开发任务和测试用例都必须输出 categoryCode
- 开发任务优先使用 frontend_development / frontend_interaction / backend_development / backend_api / database_schema / api_integration
- 测试用例优先使用 test_functional / test_api / test_exception / test_compatibility
- 不输出数据库 categoryId
- 不输出推荐负责人(用户后续手填)
5. 工时估算(小时,按团队使用 AI 辅助研发/测试估算,必须偏严格)
- 简单前端字段、文案、展示调整: 0.25-0.5h
- 简单前端交互,如拖拽排序 UI、开关、筛选项: 0.5-1h
- 拖拽排序并需要持久化接口: 1-1.5h
- 简单 CRUD 接口: 0.75-1.5h
- 数据库字段/索引调整: 0.5h
- 中等业务规则变更: 1.5-3h
- 简单功能测试用例执行: 0.25-0.5h
- API/异常/兼容性测试用例执行: 0.5-1h
- 只有跨端同步、复杂权限、历史数据迁移、强一致性、复杂兼容性时,才允许超过上述区间
6. 标题:中文动词开头,简洁
✓ "在主题列表实现拖拽排序"
✗ "关于 QY0010 主题拖拽排序的优化方案研究与实现"
7. 不凭空补
- 不要因为"通常应该有"就加"权限校验"任务
- 只拆需求和原型上明确存在的内容
【对账报告要求】
- matched: 完美对应(哪条需求 ↔ 哪些 QY ↔ 拆出多少任务)
- reqOnly: 需求里有但原型未见 → 列出需求 ID
- noteOnly: 原型里有但需求未提 → 列出 QY 编号
- ambiguous: QY 描述含糊无法转化 → 列出 QY 编号 + 含糊原因
【特殊情况】
- 若原型内容里看不到任何 QY 编号或类似的批注编号 → ambiguous 列表里标注"原型内容无可识别的批注,可能不是 PRD/原型文档"devTaskDrafts/testCaseDrafts 返回空数组
- 若原型完全无法解析 → 同上处理
【输出通道强制要求】
- 第一块响应内容必须是 submit_decompose 的 tool_use
- 不要输出任何自然语言说明、Markdown、分析过程或前置文本
- 即使无法解析原型,也必须通过 submit_decompose 返回空数组和 ambiguous 报告
通过 tool 调用 submit_decompose 工具返回结果。`;
export const DECOMPOSE_TOOL_NAME = 'submit_decompose';
export const DECOMPOSE_TOOL_DESCRIPTION = '提交原型拆解结果(开发任务草案 + 测试用例草案 + 对账报告)';
export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
type: 'object',
properties: {
report: {
type: 'object',
properties: {
matched: {
type: 'array',
items: {
type: 'object',
properties: {
reqId: { type: 'string' },
noteIds: { type: 'array', items: { type: 'string' } },
taskCount: { type: 'integer' },
},
required: ['reqId', 'noteIds', 'taskCount'],
},
},
reqOnly: { type: 'array', items: { type: 'string' } },
noteOnly: { type: 'array', items: { type: 'string' } },
ambiguous: {
type: 'array',
items: {
type: 'object',
properties: {
noteId: { type: 'string' },
reason: { type: 'string' },
},
required: ['noteId', 'reason'],
},
},
},
required: ['matched', 'reqOnly', 'noteOnly', 'ambiguous'],
},
devTaskDrafts: {
type: 'array',
items: {
type: 'object',
properties: {
title: { type: 'string' },
description: { type: 'string' },
categoryCode: {
type: 'string',
enum: [
'frontend_development',
'frontend_interaction',
'backend_development',
'backend_api',
'database_schema',
'api_integration',
'data_processing',
'implementation_support',
'documentation',
],
},
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
estimateHours: { type: 'number' },
references: {
type: 'array',
items: {
type: 'object',
properties: {
type: { type: 'string', enum: ['requirement', 'prototype_note'] },
id: { type: 'string' },
label: { type: 'string' },
},
required: ['type', 'id', 'label'],
},
minItems: 1,
},
},
required: ['title', 'categoryCode', 'priority', 'estimateHours', 'references'],
},
},
testCaseDrafts: {
type: 'array',
items: {
type: 'object',
properties: {
title: { type: 'string' },
description: { type: 'string' },
categoryCode: {
type: 'string',
enum: ['test_functional', 'test_api', 'test_exception', 'test_compatibility'],
},
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
estimateHours: { type: 'number' },
references: {
type: 'array',
items: {
type: 'object',
properties: {
type: { type: 'string', enum: ['requirement', 'prototype_note'] },
id: { type: 'string' },
label: { type: 'string' },
},
required: ['type', 'id', 'label'],
},
minItems: 1,
},
},
required: ['title', 'description', 'categoryCode', 'priority', 'estimateHours', 'references'],
},
},
},
required: ['report', 'devTaskDrafts', 'testCaseDrafts'],
};

View File

@@ -0,0 +1,27 @@
import { buildPrototypeContext } from './prototype-context';
describe('buildPrototypeContext', () => {
it('keeps QY note snippets and drops unrelated long text', () => {
const longPrefix = '无关说明'.repeat(2000);
const longSuffix = '页面噪声'.repeat(2000);
const content = `${longPrefix} QY0007支持手机号登录需要验证码输入和倒计时。${longSuffix}`;
const context = buildPrototypeContext(content, 800);
expect(context.text).toContain('QY0007');
expect(context.text).toContain('手机号登录');
expect(context.text).not.toContain(longPrefix.slice(0, 200));
expect(context.text.length).toBeLessThanOrEqual(900);
expect(context.noteCount).toBe(1);
});
it('merges overlapping QY snippets instead of duplicating the same content', () => {
const content = `${'P'.repeat(160)} QY0001 这里是第一个批注 ${'A'.repeat(180)} QY0002 这里是第二个批注 ${'B'.repeat(300)}`;
const context = buildPrototypeContext(content, 1000);
expect(context.text).toContain('QY0001');
expect(context.text).toContain('QY0002');
expect(context.text.length).toBeLessThan(800);
});
});

View File

@@ -0,0 +1,72 @@
export interface PrototypeContext {
text: string;
noteCount: number;
truncated: boolean;
}
const QY_NOTE_PATTERN = /QY\d{3,}/gi;
const MAX_NOTE_SNIPPETS = 40;
export function buildPrototypeContext(content: string, maxChars = 12000): PrototypeContext {
const normalized = content.replace(/\s+/g, ' ').trim();
const matches = Array.from(normalized.matchAll(QY_NOTE_PATTERN));
if (matches.length === 0) {
return {
text: normalized.slice(0, maxChars),
noteCount: 0,
truncated: normalized.length > maxChars,
};
}
const windowSize = Math.max(280, Math.floor(maxChars / Math.min(matches.length, 12)));
const ranges = matches.slice(0, MAX_NOTE_SNIPPETS)
.map((match) => {
const index = match.index ?? 0;
return {
start: Math.max(0, index - 120),
end: Math.min(normalized.length, index + windowSize),
};
})
.sort((a, b) => a.start - b.start);
const mergedRanges: Array<{ start: number; end: number }> = [];
for (const range of ranges) {
const last = mergedRanges[mergedRanges.length - 1];
if (!last || range.start > last.end) {
mergedRanges.push({ ...range });
} else {
last.end = Math.max(last.end, range.end);
}
}
const snippets: string[] = [];
let usedChars = 0;
let truncated = matches.length > MAX_NOTE_SNIPPETS;
for (const range of mergedRanges) {
const separatorLength = snippets.length > 0 ? '\n\n---\n\n'.length : 0;
const remaining = maxChars - usedChars - separatorLength;
if (remaining <= 0) {
truncated = true;
break;
}
const snippet = normalized.slice(range.start, range.end).trim();
if (snippet.length > remaining) {
snippets.push(snippet.slice(0, remaining).trim());
usedChars = maxChars;
truncated = true;
break;
}
snippets.push(snippet);
usedChars += separatorLength + snippet.length;
}
const text = snippets.join('\n\n---\n\n');
return {
text,
noteCount: matches.length,
truncated,
};
}

View File

@@ -0,0 +1,106 @@
import { AnthropicProvider } from './anthropic.provider';
const mockCreate = jest.fn();
jest.mock('@anthropic-ai/sdk', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => ({
messages: {
create: mockCreate,
},
})),
}));
describe('AnthropicProvider', () => {
beforeEach(() => {
mockCreate.mockReset();
});
it('reports stop reason and text preview when no tool_use block is returned', async () => {
mockCreate.mockResolvedValue({
stop_reason: 'end_turn',
model: 'claude-test',
content: [
{
type: 'text',
text: '我理解你的意思,但这里没有可识别的 QY 编号,所以我先用自然语言解释。',
},
],
usage: {
input_tokens: 12,
output_tokens: 34,
},
});
const provider = new AnthropicProvider('test-key', 'https://example.test');
await expect(
provider.callTool({
systemPrompt: '只通过工具返回',
userPrompt: '拆解这个原型',
tool: {
name: 'submit_decompose',
description: '提交拆解结果',
inputSchema: {
type: 'object',
properties: { ok: { type: 'boolean' } },
required: ['ok'],
},
},
forceTool: true,
maxTokens: 1000,
model: 'claude-test',
}),
).rejects.toThrow(
/Anthropic 未通过 tool_use 返回结果.*stop_reason=end_turn.*content=text\("我理解你的意思/,
);
});
it('disables parallel tool use when a specific tool is forced', async () => {
mockCreate.mockResolvedValue({
stop_reason: 'tool_use',
model: 'claude-test',
content: [
{
type: 'tool_use',
id: 'toolu_test',
name: 'submit_decompose',
input: { ok: true },
},
],
usage: {
input_tokens: 12,
output_tokens: 34,
},
});
const provider = new AnthropicProvider('test-key', 'https://example.test');
await provider.callTool({
systemPrompt: '只通过工具返回',
userPrompt: '拆解这个原型',
tool: {
name: 'submit_decompose',
description: '提交拆解结果',
inputSchema: {
type: 'object',
properties: { ok: { type: 'boolean' } },
required: ['ok'],
},
},
forceTool: true,
maxTokens: 1000,
model: 'claude-test',
});
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
tool_choice: {
type: 'tool',
name: 'submit_decompose',
disable_parallel_tool_use: true,
},
}),
);
});
});

View File

@@ -0,0 +1,88 @@
import Anthropic from '@anthropic-ai/sdk';
import type { AiProvider, PingRequest, PingResponse, ToolCallRequest, ToolCallResponse } from './provider.interface';
/**
* Anthropic 格式提供商(官方 API + 兼容 Anthropic 格式的中转站)
*/
export class AnthropicProvider implements AiProvider {
readonly format = 'anthropic' as const;
private client: Anthropic;
constructor(apiKey: string, baseURL?: string) {
this.client = new Anthropic({
apiKey,
...(baseURL && baseURL !== 'https://api.anthropic.com' ? { baseURL } : {}),
});
}
async callTool(req: ToolCallRequest): Promise<ToolCallResponse> {
const toolChoice = req.forceTool
? ({
type: 'tool',
name: req.tool.name,
disable_parallel_tool_use: true,
} as any)
: { type: 'auto' as const };
const response = await this.client.messages.create({
model: req.model,
max_tokens: req.maxTokens,
system: req.systemPrompt,
tools: [
{
name: req.tool.name,
description: req.tool.description,
input_schema: req.tool.inputSchema,
},
],
tool_choice: toolChoice,
messages: [{ role: 'user', content: req.userPrompt }],
});
const toolUse = response.content.find(
(c): c is Anthropic.ToolUseBlock => c.type === 'tool_use',
);
if (!toolUse) {
throw new Error(this.describeMissingToolUse(response));
}
return {
toolName: toolUse.name,
toolInput: toolUse.input as Record<string, any>,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
rawModel: response.model,
};
}
async ping(req: PingRequest): Promise<PingResponse> {
const resp = await this.client.messages.create({
model: req.model,
max_tokens: 16,
messages: [{ role: 'user', content: 'ping' }],
});
return {
ok: true,
inputTokens: resp.usage.input_tokens,
outputTokens: resp.usage.output_tokens,
};
}
private describeMissingToolUse(response: Anthropic.Message): string {
const contentSummary = response.content
.map((block) => {
if (block.type === 'text') {
const text = block.text.replace(/\s+/g, ' ').slice(0, 160);
return `text("${text}")`;
}
return block.type;
})
.join(', ');
return [
'Anthropic 未通过 tool_use 返回结果',
`stop_reason=${response.stop_reason || 'unknown'}`,
`content=${contentSummary || 'empty'}`,
].join('');
}
}

View File

@@ -0,0 +1,14 @@
import type { AiProvider } from './provider.interface';
import { AnthropicProvider } from './anthropic.provider';
import { OpenAIProvider } from './openai.provider';
import type { AiProviderConfig } from '@ftb/shared';
export function createProvider(config: AiProviderConfig): AiProvider {
if (config.format === 'anthropic') {
return new AnthropicProvider(config.apiKey, config.baseURL);
}
if (config.format === 'openai') {
return new OpenAIProvider(config.apiKey, config.baseURL);
}
throw new Error(`未支持的 provider format: ${(config as any).format}`);
}

View File

@@ -0,0 +1,79 @@
import OpenAI from 'openai';
import type { AiProvider, PingRequest, PingResponse, ToolCallRequest, ToolCallResponse } from './provider.interface';
/**
* OpenAI 格式提供商(兼容 OpenAI Chat Completions 风格的中转站)
*/
export class OpenAIProvider implements AiProvider {
readonly format = 'openai' as const;
private client: OpenAI;
constructor(apiKey: string, baseURL: string) {
this.client = new OpenAI({
apiKey,
baseURL,
});
}
async callTool(req: ToolCallRequest): Promise<ToolCallResponse> {
const response = await this.client.chat.completions.create({
model: req.model,
max_tokens: req.maxTokens,
messages: [
{ role: 'system', content: req.systemPrompt },
{ role: 'user', content: req.userPrompt },
],
tools: [
{
type: 'function',
function: {
name: req.tool.name,
description: req.tool.description,
parameters: req.tool.inputSchema,
},
},
],
tool_choice: req.forceTool
? { type: 'function', function: { name: req.tool.name } }
: 'auto',
});
const choice = response.choices[0];
if (!choice?.message?.tool_calls?.length) {
throw new Error('OpenAI 未通过 tool_calls 返回结果');
}
const tc = choice.message.tool_calls[0];
if (tc.type !== 'function') {
throw new Error('OpenAI 返回的 tool_call 类型非 function');
}
let parsed: Record<string, any>;
try {
parsed = JSON.parse(tc.function.arguments || '{}');
} catch (e) {
throw new Error(`OpenAI tool_call arguments 不是合法 JSON: ${(e as Error).message}`);
}
return {
toolName: tc.function.name,
toolInput: parsed,
inputTokens: response.usage?.prompt_tokens ?? 0,
outputTokens: response.usage?.completion_tokens ?? 0,
rawModel: response.model,
};
}
async ping(req: PingRequest): Promise<PingResponse> {
const resp = await this.client.chat.completions.create({
model: req.model,
max_tokens: 16,
messages: [{ role: 'user', content: 'ping' }],
});
return {
ok: true,
inputTokens: resp.usage?.prompt_tokens ?? 0,
outputTokens: resp.usage?.completion_tokens ?? 0,
};
}
}

View File

@@ -0,0 +1,49 @@
/**
* AI Provider 抽象接口
* 屏蔽 Anthropic vs OpenAI 格式差异,对上层提供统一调用
*/
export interface ToolDefinition {
name: string;
description: string;
inputSchema: any; // JSON Schema for tool input
}
export interface ToolCallRequest {
systemPrompt: string;
userPrompt: string;
tool: ToolDefinition;
/** 必须强制走 toolAnthropic: tool_choice / OpenAI: tool_choice */
forceTool: boolean;
maxTokens: number;
model: string;
}
export interface ToolCallResponse {
toolName: string;
toolInput: Record<string, any>;
inputTokens: number;
outputTokens: number;
rawModel: string; // 实际服务返回的 model 字段
}
export interface PingRequest {
model: string;
}
export interface PingResponse {
ok: true;
inputTokens: number;
outputTokens: number;
}
/**
* 所有 Provider 实现都满足此接口
*/
export interface AiProvider {
format: 'anthropic' | 'openai';
/** 调一次 tool 工具,返回归一化结果 */
callTool(req: ToolCallRequest): Promise<ToolCallResponse>;
/** 极简调用,验证 Key + 端点可用 */
ping(req: PingRequest): Promise<PingResponse>;
}

View File

@@ -0,0 +1,76 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
BadRequestException,
} from '@nestjs/common';
import { AiConfigService } from './ai-config.service';
import {
UpsertProviderDto,
TestProviderDto,
ActivateProviderDto,
} from './dto/update-ai-config.dto';
import { createProvider } from '../ai/providers/factory';
@Controller('config/ai')
export class AiConfigController {
constructor(private readonly svc: AiConfigService) {}
@Get()
async get() {
return this.svc.getPublic();
}
/**
* 创建或更新一个提供商
*/
@Patch('providers')
async upsert(@Body() dto: UpsertProviderDto) {
return this.svc.upsertProvider(dto, dto.operator);
}
/**
* 删除提供商
*/
@Delete('providers/:id')
async remove(@Param('id') id: string, @Query('operator') operator?: string) {
return this.svc.deleteProvider(id, operator);
}
/**
* 切换激活提供商
*/
@Post('activate')
async activate(@Body() dto: ActivateProviderDto) {
return this.svc.activate(dto.id, dto.operator);
}
/**
* 测试某个提供商连通性
*/
@Post('test')
async test(@Body() dto: TestProviderDto) {
if (!dto.id) throw new BadRequestException('id 必填');
const fullProvider = await this.svc.getProviderFullById(dto.id);
if (!fullProvider) throw new BadRequestException('提供商不存在');
try {
const provider = createProvider(fullProvider);
const r = await provider.ping({ model: fullProvider.model });
return {
ok: true,
message: `连接成功(${fullProvider.model} · 输入 ${r.inputTokens}tok 输出 ${r.outputTokens}tok`,
};
} catch (e: any) {
return {
ok: false,
error: e?.message || '调用失败',
};
}
}
}

View File

@@ -0,0 +1,200 @@
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import { promises as fs } from 'fs';
import { join } from 'path';
import type {
AiConfigPublic,
AiConfigStored,
AiProviderConfig,
AiProviderPublic,
AiProviderUpsertInput,
} from '@ftb/shared';
const CONFIG_FILE = join(process.cwd(), 'data', 'ai-config.json');
const DEFAULT_MODEL = 'claude-sonnet-4-6';
@Injectable()
export class AiConfigService {
private readonly logger = new Logger(AiConfigService.name);
private cache: AiConfigStored | null = null;
/**
* 当前激活的提供商配置(含完整 apiKey用于 AiGateway 实例化客户端
*/
async getActiveProvider(): Promise<AiProviderConfig | null> {
const cfg = await this.read();
if (!cfg.activeProviderId) {
// 兜底:环境变量
const envKey = process.env.ANTHROPIC_API_KEY;
if (envKey) {
return {
id: '__env__',
name: '环境变量',
format: 'anthropic',
baseURL: 'https://api.anthropic.com',
apiKey: envKey,
model: DEFAULT_MODEL,
createdAt: new Date(0).toISOString(),
};
}
return null;
}
return cfg.providers.find((p) => p.id === cfg.activeProviderId) ?? null;
}
/**
* 前端用的整体视图
*/
async getPublic(): Promise<AiConfigPublic> {
const cfg = await this.read();
return {
providers: cfg.providers.map((p) => this.toPublic(p, p.id === cfg.activeProviderId)),
activeProviderId: cfg.activeProviderId,
updatedAt: cfg.updatedAt,
updatedBy: cfg.updatedBy,
};
}
/**
* 创建或更新提供商
* 若 input.apiKey 为空且 id 已存在,则保留原 key
*/
async upsertProvider(input: AiProviderUpsertInput, operator?: string): Promise<AiConfigPublic> {
if (!input.id) throw new BadRequestException('id 必填');
if (!input.name) throw new BadRequestException('name 必填');
if (!input.baseURL) throw new BadRequestException('baseURL 必填');
if (!input.model) throw new BadRequestException('model 必填');
const cfg = await this.read();
const existingIdx = cfg.providers.findIndex((p) => p.id === input.id);
const apiKey = input.apiKey || cfg.providers[existingIdx]?.apiKey;
if (!apiKey) throw new BadRequestException('apiKey 必填');
const provider: AiProviderConfig = {
id: input.id,
name: input.name,
format: input.format,
baseURL: input.baseURL,
apiKey,
model: input.model,
remark: input.remark,
createdAt:
existingIdx >= 0 ? cfg.providers[existingIdx].createdAt : new Date().toISOString(),
};
if (existingIdx >= 0) {
cfg.providers[existingIdx] = provider;
} else {
cfg.providers.push(provider);
// 第一个新增的自动激活
if (!cfg.activeProviderId) cfg.activeProviderId = provider.id;
}
cfg.updatedAt = new Date().toISOString();
cfg.updatedBy = operator;
await this.write(cfg);
return this.getPublic();
}
async deleteProvider(id: string, operator?: string): Promise<AiConfigPublic> {
const cfg = await this.read();
const idx = cfg.providers.findIndex((p) => p.id === id);
if (idx < 0) throw new NotFoundException('提供商不存在');
cfg.providers.splice(idx, 1);
if (cfg.activeProviderId === id) {
cfg.activeProviderId = cfg.providers[0]?.id;
}
cfg.updatedAt = new Date().toISOString();
cfg.updatedBy = operator;
await this.write(cfg);
return this.getPublic();
}
async activate(id: string, operator?: string): Promise<AiConfigPublic> {
const cfg = await this.read();
if (!cfg.providers.find((p) => p.id === id)) {
throw new NotFoundException('提供商不存在');
}
cfg.activeProviderId = id;
cfg.updatedAt = new Date().toISOString();
cfg.updatedBy = operator;
await this.write(cfg);
return this.getPublic();
}
/**
* 取某个提供商的完整配置(含 apiKey仅供同进程内的服务调用如 ping/decompose
*/
async getProviderFullById(id: string): Promise<AiProviderConfig | null> {
const cfg = await this.read();
return cfg.providers.find((p) => p.id === id) ?? null;
}
private async read(): Promise<AiConfigStored> {
if (this.cache) return this.cache;
try {
const buf = await fs.readFile(CONFIG_FILE, 'utf-8');
const parsed = JSON.parse(buf);
this.cache = this.migrate(parsed);
return this.cache!;
} catch (e: any) {
if (e.code !== 'ENOENT') this.logger.warn(`读取 ai-config.json 失败: ${e.message}`);
this.cache = { providers: [] };
return this.cache;
}
}
/**
* 旧数据格式(单 apiKey + model迁移到新格式providers[]
*/
private migrate(raw: any): AiConfigStored {
if (Array.isArray(raw?.providers)) return raw as AiConfigStored;
// 旧格式:{ anthropicApiKey, model, updatedAt, updatedBy }
if (raw?.anthropicApiKey) {
this.logger.log('检测到旧版 ai-config.json自动迁移为多 provider 结构');
const legacy: AiProviderConfig = {
id: 'anthropic-official',
name: 'Anthropic 官方',
format: 'anthropic',
baseURL: 'https://api.anthropic.com',
apiKey: raw.anthropicApiKey,
model: raw.model || DEFAULT_MODEL,
createdAt: raw.updatedAt || new Date().toISOString(),
};
return {
providers: [legacy],
activeProviderId: legacy.id,
updatedAt: raw.updatedAt,
updatedBy: raw.updatedBy,
};
}
return { providers: [] };
}
private async write(data: AiConfigStored): Promise<void> {
await fs.mkdir(join(process.cwd(), 'data'), { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf-8');
this.cache = data;
}
private toPublic(p: AiProviderConfig, isActive: boolean): AiProviderPublic {
return {
id: p.id,
name: p.name,
format: p.format,
baseURL: p.baseURL,
keyMask: this.mask(p.apiKey),
model: p.model,
remark: p.remark,
createdAt: p.createdAt,
isActive,
};
}
private mask(key: string): string {
if (!key) return '';
if (key.length <= 10) return '****';
return `${key.slice(0, 6)}...${key.slice(-4)}`;
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AiConfigController } from './ai-config.controller';
import { AiConfigService } from './ai-config.service';
@Module({
controllers: [AiConfigController],
providers: [AiConfigService],
exports: [AiConfigService],
})
export class ConfigModule {}

View File

@@ -0,0 +1,44 @@
import { IsOptional, IsString, IsIn } from 'class-validator';
export class UpsertProviderDto {
@IsString()
id!: string;
@IsString()
name!: string;
@IsIn(['anthropic', 'openai'])
format!: 'anthropic' | 'openai';
@IsString()
baseURL!: string;
@IsOptional()
@IsString()
apiKey?: string;
@IsString()
model!: string;
@IsOptional()
@IsString()
remark?: string;
@IsOptional()
@IsString()
operator?: string;
}
export class TestProviderDto {
@IsString()
id!: string;
}
export class ActivateProviderDto {
@IsString()
id!: string;
@IsOptional()
@IsString()
operator?: string;
}

View File

@@ -0,0 +1,18 @@
export const APP_DATA_KEYS = [
'products-overview',
'requirements',
'version-plans',
'dev-tasks',
'test-cases',
'bugs',
'members',
'task-categories',
'task-worklogs',
'overtime',
] as const;
export type AppDataKey = (typeof APP_DATA_KEYS)[number];
export function isAppDataKey(key: string): key is AppDataKey {
return (APP_DATA_KEYS as readonly string[]).includes(key);
}

View File

@@ -0,0 +1,17 @@
import { Body, Controller, Get, Param, Put } from '@nestjs/common';
import { DataService } from './data.service';
@Controller('data')
export class DataController {
constructor(private readonly dataService: DataService) {}
@Get(':key')
get(@Param('key') key: string) {
return this.dataService.get(key);
}
@Put(':key')
put(@Param('key') key: string, @Body('value') value: unknown) {
return this.dataService.put(key, value);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DataController } from './data.controller';
import { DataService } from './data.service';
@Module({
controllers: [DataController],
providers: [DataService],
})
export class DataModule {}

View File

@@ -0,0 +1,66 @@
import { BadRequestException } from '@nestjs/common';
import { DataService } from './data.service';
describe('DataService', () => {
const makeService = () => {
const prisma = {
appData: {
findUnique: jest.fn(),
upsert: jest.fn(),
},
};
return {
prisma,
service: new DataService(prisma as any),
};
};
it('returns null for an allowed key with no stored value', async () => {
const { prisma, service } = makeService();
prisma.appData.findUnique.mockResolvedValue(null);
await expect(service.get('products-overview')).resolves.toEqual({
key: 'products-overview',
value: null,
});
});
it('rejects unknown keys', async () => {
const { service } = makeService();
await expect(service.get('unknown-key')).rejects.toBeInstanceOf(BadRequestException);
});
it('upserts JSON values for allowed keys', async () => {
const { prisma, service } = makeService();
const value = [{ id: 'p1', name: 'Product 1' }];
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value });
await expect(service.put('products-overview', value)).resolves.toEqual({
key: 'products-overview',
value,
});
expect(prisma.appData.upsert).toHaveBeenCalledWith({
where: { key: 'products-overview' },
update: { value },
create: { key: 'products-overview', value },
});
});
it('allows supporting business data keys migrated from browser storage', async () => {
const { prisma, service } = makeService();
const value: unknown[] = [];
prisma.appData.upsert.mockImplementation(({ where }) =>
Promise.resolve({ key: where.key, value }),
);
await expect(service.put('task-worklogs', value)).resolves.toEqual({
key: 'task-worklogs',
value,
});
await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toEqual({
key: 'overtime',
value,
});
});
});

View File

@@ -0,0 +1,32 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { isAppDataKey } from './data-keys';
@Injectable()
export class DataService {
constructor(private prisma: PrismaService) {}
async get(key: string) {
this.ensureAllowedKey(key);
const row = await this.prisma.appData.findUnique({ where: { key } });
return { key, value: row?.value ?? null };
}
async put(key: string, value: unknown) {
this.ensureAllowedKey(key);
const jsonValue = value as Prisma.InputJsonValue;
const row = await this.prisma.appData.upsert({
where: { key },
update: { value: jsonValue },
create: { key, value: jsonValue },
});
return { key: row.key, value: row.value };
}
private ensureAllowedKey(key: string) {
if (!isAppDataKey(key)) {
throw new BadRequestException(`Unsupported data key: ${key}`);
}
}
}

View File

@@ -1,13 +1,26 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
private connected = false;
async onModuleInit() {
await this.$connect();
try {
await this.$connect();
this.connected = true;
this.logger.log('Prisma connected');
} catch (e: any) {
this.logger.warn(
`数据库连接失败 (${e?.errorCode || e?.message || e}) — 跳过,需要 DB 的接口将不可用,但不影响 AI 配置 / 拆解模块`,
);
}
}
async onModuleDestroy() {
await this.$disconnect();
if (this.connected) {
await this.$disconnect();
}
}
}