# GEO 分析系统 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 构建一个内部 GEO 分析工具,支持多公司管理、关键字/竞品配置、大模型 API 管理、分析任务调度和结果对比看板。
**Architecture:** NestJS 后端(REST API + Bull 队列 Worker)+ React 前端(Ant Design + Recharts)+ PostgreSQL + Redis。分析引擎以关键字×模型为维度并发调用 AI API,解析回答提取品牌提及率/情感/竞品出现次数,结果持久化后通过看板聚合展示。
**Tech Stack:** NestJS 10, TypeORM, PostgreSQL 15, Redis 7, Bull, React 18, Vite, Ant Design 5, Recharts, Docker Compose
---
## File Map
### Backend
```
backend/
├── src/
│ ├── main.ts
│ ├── app.module.ts
│ ├── common/
│ │ └── crypto.util.ts # AES-256 加密/解密
│ ├── companies/
│ │ ├── entities/
│ │ │ ├── company.entity.ts
│ │ │ ├── keyword.entity.ts
│ │ │ └── competitor.entity.ts
│ │ ├── dto/
│ │ │ ├── create-company.dto.ts
│ │ │ ├── create-keyword.dto.ts
│ │ │ └── create-competitor.dto.ts
│ │ ├── companies.module.ts
│ │ ├── companies.controller.ts
│ │ ├── companies.service.ts
│ │ ├── keywords.controller.ts
│ │ ├── keywords.service.ts
│ │ ├── competitors.controller.ts
│ │ └── competitors.service.ts
│ ├── ai-models/
│ │ ├── entities/
│ │ │ └── ai-model.entity.ts
│ │ ├── dto/
│ │ │ └── create-ai-model.dto.ts
│ │ ├── ai-models.module.ts
│ │ ├── ai-models.controller.ts
│ │ └── ai-models.service.ts
│ ├── tasks/
│ │ ├── entities/
│ │ │ ├── analysis-task.entity.ts
│ │ │ └── analysis-result.entity.ts
│ │ ├── dto/
│ │ │ └── create-task.dto.ts
│ │ ├── tasks.module.ts
│ │ ├── tasks.controller.ts
│ │ └── tasks.service.ts
│ ├── analysis/
│ │ ├── adapters/
│ │ │ ├── base.adapter.ts
│ │ │ ├── openai-compatible.adapter.ts # OpenAI/DeepSeek/Kimi/Qwen
│ │ │ ├── anthropic.adapter.ts
│ │ │ └── gemini.adapter.ts
│ │ ├── analysis.module.ts
│ │ ├── analysis.service.ts # Prompt 构造 + 回答解析
│ │ └── response-parser.ts # 品牌提及/情感/竞品提取
│ ├── queue/
│ │ ├── queue.module.ts
│ │ └── analysis.processor.ts # Bull worker
│ └── dashboard/
│ ├── dashboard.module.ts
│ ├── dashboard.controller.ts
│ └── dashboard.service.ts
├── test/
│ ├── companies.service.spec.ts
│ ├── ai-models.service.spec.ts
│ ├── response-parser.spec.ts
│ └── dashboard.service.spec.ts
├── .env.example
├── package.json
├── tsconfig.json
└── Dockerfile
```
### Frontend
```
frontend/
├── src/
│ ├── main.tsx
│ ├── App.tsx # Router + Layout
│ ├── types/index.ts # 共享 TS 类型
│ ├── api/
│ │ ├── client.ts # axios 实例
│ │ ├── companies.ts
│ │ ├── ai-models.ts
│ │ ├── tasks.ts
│ │ └── dashboard.ts
│ ├── components/
│ │ └── AppLayout.tsx # 侧边栏导航
│ └── pages/
│ ├── companies/
│ │ ├── CompanyListPage.tsx
│ │ └── CompanyDetailPage.tsx # Tab: 关键字/竞品/任务
│ ├── ai-models/
│ │ └── AIModelsPage.tsx
│ ├── tasks/
│ │ ├── TaskListPage.tsx
│ │ └── TaskResultsPage.tsx
│ └── dashboard/
│ └── DashboardPage.tsx
├── package.json
├── vite.config.ts
├── tsconfig.json
└── Dockerfile
```
### Root
```
docker-compose.yml
.env.example
nginx/default.conf
```
---
## Task 1: 项目脚手架 + Docker Compose
**Files:**
- Create: `docker-compose.yml`
- Create: `.env.example`
- Create: `backend/package.json`
- Create: `backend/tsconfig.json`
- Create: `backend/src/main.ts`
- Create: `backend/src/app.module.ts`
- Create: `backend/Dockerfile`
- Create: `frontend/package.json`
- Create: `frontend/vite.config.ts`
- Create: `frontend/tsconfig.json`
- Create: `frontend/index.html`
- Create: `frontend/src/main.tsx`
- Create: `frontend/Dockerfile`
- [ ] **Step 1: 创建 docker-compose.yml**
```yaml
# docker-compose.yml
version: '3.9'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: geo
POSTGRES_USER: geo
POSTGRES_PASSWORD: geo123
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
backend:
build: ./backend
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://geo:geo123@postgres:5432/geo
REDIS_URL: redis://redis:6379
CRYPTO_KEY: change-this-32-char-secret-key!!
ANALYSIS_CONCURRENCY: 3
depends_on:
- postgres
- redis
volumes:
- ./backend:/app
- /app/node_modules
frontend:
build: ./frontend
ports:
- "5173:5173"
volumes:
- ./frontend:/app
- /app/node_modules
environment:
VITE_API_URL: http://localhost:3000
volumes:
postgres_data:
```
- [ ] **Step 2: 创建 .env.example**
```bash
# .env.example
DATABASE_URL=postgresql://geo:geo123@localhost:5432/geo
REDIS_URL=redis://localhost:6379
CRYPTO_KEY=change-this-32-char-secret-key!!
ANALYSIS_CONCURRENCY=3
PORT=3000
```
- [ ] **Step 3: 初始化 NestJS 后端**
```bash
cd /d/geo2
mkdir -p backend/src/common backend/src/companies/entities backend/src/companies/dto \
backend/src/ai-models/entities backend/src/ai-models/dto \
backend/src/tasks/entities backend/src/tasks/dto \
backend/src/analysis/adapters backend/src/queue backend/src/dashboard \
backend/test
```
- [ ] **Step 4: 创建 backend/package.json**
```json
{
"name": "geo-backend",
"version": "1.0.0",
"scripts": {
"start:dev": "nest start --watch",
"build": "nest build",
"test": "jest --passWithNoTests"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/typeorm": "^10.0.0",
"@nestjs/config": "^3.0.0",
"@nestjs/bull": "^10.0.0",
"@nestjs/schedule": "^4.0.0",
"typeorm": "^0.3.17",
"pg": "^8.11.0",
"bull": "^4.12.0",
"ioredis": "^5.3.0",
"class-validator": "^0.14.0",
"class-transformer": "^0.5.1",
"openai": "^4.20.0",
"@anthropic-ai/sdk": "^0.20.0",
"@google/generative-ai": "^0.7.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/node": "^20.0.0",
"@types/jest": "^29.0.0",
"jest": "^29.0.0",
"ts-jest": "^29.0.0",
"typescript": "^5.0.0"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testRegex": ".*\\.spec\\.ts$",
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
"testEnvironment": "node"
}
}
```
- [ ] **Step 5: 创建 backend/tsconfig.json**
```json
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"strict": false,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}
```
- [ ] **Step 6: 创建 backend/src/main.ts**
```typescript
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.enableCors();
await app.listen(process.env.PORT ?? 3000);
console.log(`Backend running on port ${process.env.PORT ?? 3000}`);
}
bootstrap();
```
- [ ] **Step 7: 创建 backend/src/app.module.ts (空骨架,后续各 Task 会补充)**
```typescript
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRoot({
type: 'postgres',
url: process.env.DATABASE_URL,
autoLoadEntities: true,
synchronize: true, // dev only
}),
BullModule.forRoot({
redis: process.env.REDIS_URL ?? 'redis://localhost:6379',
}),
ScheduleModule.forRoot(),
],
})
export class AppModule {}
```
- [ ] **Step 8: 创建 backend/Dockerfile**
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "start:dev"]
```
- [ ] **Step 9: 初始化前端项目**
```bash
cd /d/geo2
mkdir -p frontend/src/api frontend/src/components frontend/src/types \
frontend/src/pages/companies frontend/src/pages/ai-models \
frontend/src/pages/tasks frontend/src/pages/dashboard
```
- [ ] **Step 10: 创建 frontend/package.json**
```json
{
"name": "geo-frontend",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"antd": "^5.12.0",
"@ant-design/icons": "^5.2.0",
"recharts": "^2.10.0",
"axios": "^1.6.0",
"dayjs": "^1.11.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
}
```
- [ ] **Step 11: 创建 frontend/vite.config.ts**
```typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_API_URL ?? 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});
```
- [ ] **Step 12: 创建 frontend/tsconfig.json**
```json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": false
},
"include": ["src"]
}
```
- [ ] **Step 13: 创建 frontend/index.html**
```html
GEO 分析系统
```
- [ ] **Step 14: 创建 frontend/Dockerfile**
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]
```
- [ ] **Step 15: 安装依赖并验证**
```bash
cd /d/geo2/backend && npm install
cd /d/geo2/frontend && npm install
```
Expected: 无报错,node_modules 创建成功。
- [ ] **Step 16: Commit**
```bash
cd /d/geo2
git init
git add .
git commit -m "feat: project scaffold - NestJS backend + React frontend + Docker Compose"
```
---
## Task 2: 公共工具 + 数据库实体
**Files:**
- Create: `backend/src/common/crypto.util.ts`
- Create: `backend/src/companies/entities/company.entity.ts`
- Create: `backend/src/companies/entities/keyword.entity.ts`
- Create: `backend/src/companies/entities/competitor.entity.ts`
- Create: `backend/src/ai-models/entities/ai-model.entity.ts`
- Create: `backend/src/tasks/entities/analysis-task.entity.ts`
- Create: `backend/src/tasks/entities/analysis-result.entity.ts`
- Test: `backend/test/crypto.spec.ts`
- [ ] **Step 1: 编写 crypto.util 失败测试**
```typescript
// backend/test/crypto.spec.ts
import { encrypt, decrypt } from '../src/common/crypto.util';
describe('crypto.util', () => {
it('should encrypt and decrypt a string', () => {
const original = 'sk-test-api-key-12345';
const encrypted = encrypt(original);
expect(encrypted).not.toBe(original);
const decrypted = decrypt(encrypted);
expect(decrypted).toBe(original);
});
it('should produce different ciphertext each time (IV randomization)', () => {
const text = 'same-key';
expect(encrypt(text)).not.toBe(encrypt(text));
});
});
```
- [ ] **Step 2: 运行测试,确认失败**
```bash
cd /d/geo2/backend && npx jest test/crypto.spec.ts --no-coverage
```
Expected: FAIL — "Cannot find module '../src/common/crypto.util'"
- [ ] **Step 3: 实现 crypto.util.ts**
```typescript
// backend/src/common/crypto.util.ts
import * as crypto from 'crypto';
const ALGORITHM = 'aes-256-cbc';
const KEY = Buffer.from(
(process.env.CRYPTO_KEY ?? 'default-32-char-key-do-not-use!!').slice(0, 32),
);
export function encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
return iv.toString('hex') + ':' + encrypted.toString('hex');
}
export function decrypt(ciphertext: string): string {
const [ivHex, encryptedHex] = ciphertext.split(':');
const iv = Buffer.from(ivHex, 'hex');
const encrypted = Buffer.from(encryptedHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
}
```
- [ ] **Step 4: 运行测试,确认通过**
```bash
cd /d/geo2/backend && npx jest test/crypto.spec.ts --no-coverage
```
Expected: PASS (2 tests)
- [ ] **Step 5: 创建 company.entity.ts**
```typescript
// backend/src/companies/entities/company.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany } from 'typeorm';
import { Keyword } from './keyword.entity';
import { Competitor } from './competitor.entity';
@Entity('companies')
export class Company {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ nullable: true })
industry: string;
@Column({ nullable: true, type: 'text' })
description: string;
@CreateDateColumn()
createdAt: Date;
@OneToMany(() => Keyword, (k) => k.company, { cascade: true })
keywords: Keyword[];
@OneToMany(() => Competitor, (c) => c.company, { cascade: true })
competitors: Competitor[];
}
```
- [ ] **Step 6: 创建 keyword.entity.ts**
```typescript
// backend/src/companies/entities/keyword.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Company } from './company.entity';
@Entity('keywords')
export class Keyword {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
companyId: string;
@Column()
text: string;
@Column({ nullable: true })
category: string;
@CreateDateColumn()
createdAt: Date;
@ManyToOne(() => Company, (c) => c.keywords, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'companyId' })
company: Company;
}
```
- [ ] **Step 7: 创建 competitor.entity.ts**
```typescript
// backend/src/companies/entities/competitor.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Company } from './company.entity';
@Entity('competitors')
export class Competitor {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
companyId: string;
@Column()
name: string;
@Column({ type: 'simple-array', nullable: true })
aliases: string[];
@CreateDateColumn()
createdAt: Date;
@ManyToOne(() => Company, (c) => c.competitors, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'companyId' })
company: Company;
}
```
- [ ] **Step 8: 创建 ai-model.entity.ts**
```typescript
// backend/src/ai-models/entities/ai-model.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
export type ModelProvider = 'openai' | 'anthropic' | 'gemini' | 'deepseek' | 'kimi' | 'qwen' | 'ernie';
@Entity('ai_models')
export class AiModel {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column()
provider: string;
@Column({ type: 'text' })
apiKeyEncrypted: string;
@Column({ nullable: true })
baseUrl: string;
@Column()
modelId: string;
@Column({ default: true })
isEnabled: boolean;
@Column({ nullable: true, type: 'timestamp' })
lastTestedAt: Date;
@CreateDateColumn()
createdAt: Date;
}
```
- [ ] **Step 9: 创建 analysis-task.entity.ts**
```typescript
// backend/src/tasks/entities/analysis-task.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Company } from '../../companies/entities/company.entity';
export type TaskStatus = 'pending' | 'running' | 'done' | 'failed';
@Entity('analysis_tasks')
export class AnalysisTask {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
companyId: string;
@Column()
name: string;
@Column({ type: 'simple-array' })
keywordIds: string[];
@Column({ type: 'simple-array' })
competitorIds: string[];
@Column({ type: 'simple-array' })
modelIds: string[];
@Column({ nullable: true })
schedule: string; // cron expression or null
@Column({ default: 'pending' })
status: TaskStatus;
@Column({ nullable: true, type: 'timestamp' })
lastRunAt: Date;
@CreateDateColumn()
createdAt: Date;
@ManyToOne(() => Company)
@JoinColumn({ name: 'companyId' })
company: Company;
}
```
- [ ] **Step 10: 创建 analysis-result.entity.ts**
```typescript
// backend/src/tasks/entities/analysis-result.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { AnalysisTask } from './analysis-task.entity';
import { AiModel } from '../../ai-models/entities/ai-model.entity';
export type Sentiment = 'positive' | 'neutral' | 'negative';
@Entity('analysis_results')
export class AnalysisResult {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
taskId: string;
@Column()
modelId: string;
@Column()
keyword: string;
@Column({ type: 'text' })
prompt: string;
@Column({ type: 'text' })
rawResponse: string;
@Column({ default: false })
brandMentioned: boolean;
@Column({ default: 0 })
mentionCount: number;
@Column({ default: 'neutral' })
sentiment: Sentiment;
@Column({ type: 'jsonb', default: '[]' })
competitorMentions: Array<{ name: string; count: number }>;
@Column({ type: 'simple-array', nullable: true })
sources: string[];
@Column({ nullable: true, type: 'text' })
errorMessage: string;
@CreateDateColumn()
createdAt: Date;
@ManyToOne(() => AnalysisTask)
@JoinColumn({ name: 'taskId' })
task: AnalysisTask;
@ManyToOne(() => AiModel)
@JoinColumn({ name: 'modelId' })
model: AiModel;
}
```
- [ ] **Step 11: Commit**
```bash
cd /d/geo2
git add backend/src/common backend/src/companies/entities backend/src/ai-models/entities backend/src/tasks/entities backend/test/crypto.spec.ts
git commit -m "feat: crypto util + all TypeORM entities"
```
---
## Task 3: 公司模块 (Companies CRUD)
**Files:**
- Create: `backend/src/companies/dto/create-company.dto.ts`
- Create: `backend/src/companies/companies.service.ts`
- Create: `backend/src/companies/companies.controller.ts`
- Create: `backend/src/companies/companies.module.ts`
- Modify: `backend/src/app.module.ts`
- Test: `backend/test/companies.service.spec.ts`
- [ ] **Step 1: 编写 CompaniesService 失败测试**
```typescript
// backend/test/companies.service.spec.ts
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { CompaniesService } from '../src/companies/companies.service';
import { Company } from '../src/companies/entities/company.entity';
const mockRepo = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
describe('CompaniesService', () => {
let service: CompaniesService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
CompaniesService,
{ provide: getRepositoryToken(Company), useValue: mockRepo },
],
}).compile();
service = module.get(CompaniesService);
jest.clearAllMocks();
});
it('should return all companies', async () => {
const companies = [{ id: '1', name: '蓝卓' }];
mockRepo.find.mockResolvedValue(companies);
expect(await service.findAll()).toEqual(companies);
expect(mockRepo.find).toHaveBeenCalledWith({ relations: ['keywords', 'competitors'] });
});
it('should create a company', async () => {
const dto = { name: '测试公司', industry: 'IT' };
mockRepo.create.mockReturnValue(dto);
mockRepo.save.mockResolvedValue({ id: 'uuid', ...dto });
const result = await service.create(dto as any);
expect(result.name).toBe('测试公司');
});
});
```
- [ ] **Step 2: 运行测试,确认失败**
```bash
cd /d/geo2/backend && npx jest test/companies.service.spec.ts --no-coverage
```
Expected: FAIL — "Cannot find module '../src/companies/companies.service'"
- [ ] **Step 3: 创建 create-company.dto.ts**
```typescript
// backend/src/companies/dto/create-company.dto.ts
import { IsString, IsOptional } from 'class-validator';
export class CreateCompanyDto {
@IsString()
name: string;
@IsOptional()
@IsString()
industry?: string;
@IsOptional()
@IsString()
description?: string;
}
```
- [ ] **Step 4: 创建 companies.service.ts**
```typescript
// backend/src/companies/companies.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Company } from './entities/company.entity';
import { CreateCompanyDto } from './dto/create-company.dto';
@Injectable()
export class CompaniesService {
constructor(
@InjectRepository(Company)
private readonly repo: Repository,
) {}
findAll(): Promise {
return this.repo.find({ relations: ['keywords', 'competitors'] });
}
async findOne(id: string): Promise {
const company = await this.repo.findOne({
where: { id },
relations: ['keywords', 'competitors'],
});
if (!company) throw new NotFoundException(`Company ${id} not found`);
return company;
}
create(dto: CreateCompanyDto): Promise {
return this.repo.save(this.repo.create(dto));
}
async update(id: string, dto: Partial): Promise {
await this.findOne(id); // throws if not found
await this.repo.update(id, dto);
return this.findOne(id);
}
async remove(id: string): Promise {
await this.findOne(id);
await this.repo.delete(id);
}
}
```
- [ ] **Step 5: 创建 companies.controller.ts**
```typescript
// backend/src/companies/companies.controller.ts
import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common';
import { CompaniesService } from './companies.service';
import { CreateCompanyDto } from './dto/create-company.dto';
@Controller('companies')
export class CompaniesController {
constructor(private readonly service: CompaniesService) {}
@Get()
findAll() {
return this.service.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.service.findOne(id);
}
@Post()
create(@Body() dto: CreateCompanyDto) {
return this.service.create(dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: Partial) {
return this.service.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.service.remove(id);
}
}
```
- [ ] **Step 6: 创建 companies.module.ts**
```typescript
// backend/src/companies/companies.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Company } from './entities/company.entity';
import { Keyword } from './entities/keyword.entity';
import { Competitor } from './entities/competitor.entity';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { KeywordsController } from './keywords.controller';
import { KeywordsService } from './keywords.service';
import { CompetitorsController } from './competitors.controller';
import { CompetitorsService } from './competitors.service';
@Module({
imports: [TypeOrmModule.forFeature([Company, Keyword, Competitor])],
controllers: [CompaniesController, KeywordsController, CompetitorsController],
providers: [CompaniesService, KeywordsService, CompetitorsService],
exports: [CompaniesService, KeywordsService, CompetitorsService],
})
export class CompaniesModule {}
```
- [ ] **Step 7: 运行测试,确认通过**
```bash
cd /d/geo2/backend && npx jest test/companies.service.spec.ts --no-coverage
```
Expected: PASS (2 tests)
- [ ] **Step 8: 创建关键字和竞品的 DTOs、Services、Controllers**
```typescript
// backend/src/companies/dto/create-keyword.dto.ts
import { IsString, IsOptional } from 'class-validator';
export class CreateKeywordDto {
@IsString() text: string;
@IsOptional() @IsString() category?: string;
}
// backend/src/companies/dto/create-competitor.dto.ts
import { IsString, IsOptional, IsArray } from 'class-validator';
export class CreateCompetitorDto {
@IsString() name: string;
@IsOptional() @IsArray() aliases?: string[];
}
```
```typescript
// backend/src/companies/keywords.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Keyword } from './entities/keyword.entity';
import { CreateKeywordDto } from './dto/create-keyword.dto';
@Injectable()
export class KeywordsService {
constructor(@InjectRepository(Keyword) private readonly repo: Repository) {}
findByCompany(companyId: string): Promise {
return this.repo.find({ where: { companyId } });
}
create(companyId: string, dto: CreateKeywordDto): Promise {
return this.repo.save(this.repo.create({ ...dto, companyId }));
}
async remove(companyId: string, id: string): Promise {
const kw = await this.repo.findOne({ where: { id, companyId } });
if (!kw) throw new NotFoundException(`Keyword ${id} not found`);
await this.repo.delete(id);
}
}
```
```typescript
// backend/src/companies/keywords.controller.ts
import { Controller, Get, Post, Delete, Body, Param } from '@nestjs/common';
import { KeywordsService } from './keywords.service';
import { CreateKeywordDto } from './dto/create-keyword.dto';
@Controller('companies/:companyId/keywords')
export class KeywordsController {
constructor(private readonly service: KeywordsService) {}
@Get()
findAll(@Param('companyId') companyId: string) {
return this.service.findByCompany(companyId);
}
@Post()
create(@Param('companyId') companyId: string, @Body() dto: CreateKeywordDto) {
return this.service.create(companyId, dto);
}
@Delete(':id')
remove(@Param('companyId') companyId: string, @Param('id') id: string) {
return this.service.remove(companyId, id);
}
}
```
```typescript
// backend/src/companies/competitors.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Competitor } from './entities/competitor.entity';
import { CreateCompetitorDto } from './dto/create-competitor.dto';
@Injectable()
export class CompetitorsService {
constructor(@InjectRepository(Competitor) private readonly repo: Repository) {}
findByCompany(companyId: string): Promise {
return this.repo.find({ where: { companyId } });
}
create(companyId: string, dto: CreateCompetitorDto): Promise {
return this.repo.save(this.repo.create({ ...dto, companyId }));
}
async remove(companyId: string, id: string): Promise {
const comp = await this.repo.findOne({ where: { id, companyId } });
if (!comp) throw new NotFoundException(`Competitor ${id} not found`);
await this.repo.delete(id);
}
}
```
```typescript
// backend/src/companies/competitors.controller.ts
import { Controller, Get, Post, Delete, Body, Param } from '@nestjs/common';
import { CompetitorsService } from './competitors.service';
import { CreateCompetitorDto } from './dto/create-competitor.dto';
@Controller('companies/:companyId/competitors')
export class CompetitorsController {
constructor(private readonly service: CompetitorsService) {}
@Get()
findAll(@Param('companyId') companyId: string) {
return this.service.findByCompany(companyId);
}
@Post()
create(@Param('companyId') companyId: string, @Body() dto: CreateCompetitorDto) {
return this.service.create(companyId, dto);
}
@Delete(':id')
remove(@Param('companyId') companyId: string, @Param('id') id: string) {
return this.service.remove(companyId, id);
}
}
```
- [ ] **Step 9: 注册 CompaniesModule 到 AppModule**
在 `backend/src/app.module.ts` 的 imports 数组中添加:
```typescript
import { CompaniesModule } from './companies/companies.module';
// 在 @Module imports 中加入:
CompaniesModule,
```
- [ ] **Step 10: Commit**
```bash
cd /d/geo2
git add backend/src/companies backend/test/companies.service.spec.ts
git commit -m "feat: companies module with keywords and competitors CRUD"
```
---
## Task 4: AI 模型管理模块
**Files:**
- Create: `backend/src/ai-models/dto/create-ai-model.dto.ts`
- Create: `backend/src/ai-models/ai-models.service.ts`
- Create: `backend/src/ai-models/ai-models.controller.ts`
- Create: `backend/src/ai-models/ai-models.module.ts`
- Modify: `backend/src/app.module.ts`
- Test: `backend/test/ai-models.service.spec.ts`
- [ ] **Step 1: 编写 AiModelsService 失败测试**
```typescript
// backend/test/ai-models.service.spec.ts
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AiModelsService } from '../src/ai-models/ai-models.service';
import { AiModel } from '../src/ai-models/entities/ai-model.entity';
const mockRepo = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
describe('AiModelsService', () => {
let service: AiModelsService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
AiModelsService,
{ provide: getRepositoryToken(AiModel), useValue: mockRepo },
],
}).compile();
service = module.get(AiModelsService);
jest.clearAllMocks();
});
it('should mask apiKey when returning models', async () => {
mockRepo.find.mockResolvedValue([
{ id: '1', name: 'DeepSeek', apiKeyEncrypted: 'encrypted-value' },
]);
const result = await service.findAll();
expect(result[0]).not.toHaveProperty('apiKeyEncrypted');
expect(result[0]).toHaveProperty('apiKeyMasked');
});
it('should store encrypted apiKey on create', async () => {
const dto = { name: 'DeepSeek V3', provider: 'deepseek', apiKey: 'sk-real-key', modelId: 'deepseek-chat', baseUrl: 'https://api.deepseek.com' };
mockRepo.create.mockImplementation((data) => data);
mockRepo.save.mockImplementation((data) => Promise.resolve({ id: 'uuid', ...data }));
await service.create(dto as any);
const savedData = mockRepo.save.mock.calls[0][0];
expect(savedData.apiKeyEncrypted).not.toBe('sk-real-key');
expect(savedData).not.toHaveProperty('apiKey');
});
});
```
- [ ] **Step 2: 运行测试,确认失败**
```bash
cd /d/geo2/backend && npx jest test/ai-models.service.spec.ts --no-coverage
```
Expected: FAIL
- [ ] **Step 3: 创建 create-ai-model.dto.ts**
```typescript
// backend/src/ai-models/dto/create-ai-model.dto.ts
import { IsString, IsOptional, IsBoolean } from 'class-validator';
export class CreateAiModelDto {
@IsString() name: string;
@IsString() provider: string;
@IsString() apiKey: string;
@IsOptional() @IsString() baseUrl?: string;
@IsString() modelId: string;
@IsOptional() @IsBoolean() isEnabled?: boolean;
}
```
- [ ] **Step 4: 创建 ai-models.service.ts**
```typescript
// backend/src/ai-models/ai-models.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AiModel } from './entities/ai-model.entity';
import { CreateAiModelDto } from './dto/create-ai-model.dto';
import { encrypt, decrypt } from '../common/crypto.util';
@Injectable()
export class AiModelsService {
constructor(
@InjectRepository(AiModel)
private readonly repo: Repository,
) {}
async findAll(): Promise {
const models = await this.repo.find();
return models.map((m) => this.maskKey(m));
}
async findOne(id: string): Promise {
const model = await this.repo.findOne({ where: { id } });
if (!model) throw new NotFoundException(`Model ${id} not found`);
return model;
}
async getDecryptedKey(id: string): Promise {
const model = await this.findOne(id);
return decrypt(model.apiKeyEncrypted);
}
async create(dto: CreateAiModelDto): Promise {
const { apiKey, ...rest } = dto;
const entity = this.repo.create({ ...rest, apiKeyEncrypted: encrypt(apiKey) });
const saved = await this.repo.save(entity);
return this.maskKey(saved);
}
async update(id: string, dto: Partial): Promise {
await this.findOne(id);
const { apiKey, ...rest } = dto as any;
const update: any = { ...rest };
if (apiKey) update.apiKeyEncrypted = encrypt(apiKey);
await this.repo.update(id, update);
return this.maskKey(await this.findOne(id));
}
async remove(id: string): Promise {
await this.findOne(id);
await this.repo.delete(id);
}
async testConnection(id: string): Promise<{ success: boolean; message: string }> {
const model = await this.findOne(id);
const apiKey = decrypt(model.apiKeyEncrypted);
try {
// 发送一个轻量请求验证 key 是否有效
const response = await fetch(model.baseUrl ?? this.getDefaultBaseUrl(model.provider) + '/models', {
headers: { Authorization: `Bearer ${apiKey}` },
});
const success = response.ok || response.status === 200;
await this.repo.update(id, { lastTestedAt: new Date() });
return { success, message: success ? 'Connection successful' : `HTTP ${response.status}` };
} catch (e) {
return { success: false, message: e.message };
}
}
private getDefaultBaseUrl(provider: string): string {
const map: Record = {
openai: 'https://api.openai.com/v1',
deepseek: 'https://api.deepseek.com/v1',
kimi: 'https://api.moonshot.cn/v1',
qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
ernie: 'https://aip.baidubce.com/rpc/2.0/ai_custom/v1',
anthropic: 'https://api.anthropic.com',
gemini: 'https://generativelanguage.googleapis.com/v1beta',
};
return map[provider] ?? 'https://api.openai.com/v1';
}
private maskKey(model: AiModel): any {
const { apiKeyEncrypted, ...rest } = model as any;
const decrypted = decrypt(apiKeyEncrypted);
const apiKeyMasked = '••••••••' + decrypted.slice(-4);
return { ...rest, apiKeyMasked };
}
}
```
- [ ] **Step 5: 创建 ai-models.controller.ts**
```typescript
// backend/src/ai-models/ai-models.controller.ts
import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common';
import { AiModelsService } from './ai-models.service';
import { CreateAiModelDto } from './dto/create-ai-model.dto';
@Controller('ai-models')
export class AiModelsController {
constructor(private readonly service: AiModelsService) {}
@Get()
findAll() { return this.service.findAll(); }
@Post()
create(@Body() dto: CreateAiModelDto) { return this.service.create(dto); }
@Patch(':id')
update(@Param('id') id: string, @Body() dto: Partial) {
return this.service.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) { return this.service.remove(id); }
@Post(':id/test')
test(@Param('id') id: string) { return this.service.testConnection(id); }
}
```
- [ ] **Step 6: 创建 ai-models.module.ts**
```typescript
// backend/src/ai-models/ai-models.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiModel } from './entities/ai-model.entity';
import { AiModelsController } from './ai-models.controller';
import { AiModelsService } from './ai-models.service';
@Module({
imports: [TypeOrmModule.forFeature([AiModel])],
controllers: [AiModelsController],
providers: [AiModelsService],
exports: [AiModelsService],
})
export class AiModelsModule {}
```
- [ ] **Step 7: 注册 AiModelsModule 到 AppModule,运行测试**
在 `backend/src/app.module.ts` imports 中添加 `AiModelsModule`。
```bash
cd /d/geo2/backend && npx jest test/ai-models.service.spec.ts --no-coverage
```
Expected: PASS (2 tests)
- [ ] **Step 8: Commit**
```bash
cd /d/geo2
git add backend/src/ai-models backend/test/ai-models.service.spec.ts
git commit -m "feat: ai-models module with AES-256 key encryption"
```
---
## Task 5: 分析引擎 — Prompt 构造 + 回答解析 + AI 适配器
**Files:**
- Create: `backend/src/analysis/adapters/base.adapter.ts`
- Create: `backend/src/analysis/adapters/openai-compatible.adapter.ts`
- Create: `backend/src/analysis/adapters/anthropic.adapter.ts`
- Create: `backend/src/analysis/adapters/gemini.adapter.ts`
- Create: `backend/src/analysis/response-parser.ts`
- Create: `backend/src/analysis/analysis.service.ts`
- Create: `backend/src/analysis/analysis.module.ts`
- Test: `backend/test/response-parser.spec.ts`
- [ ] **Step 1: 编写 response-parser 失败测试**
```typescript
// backend/test/response-parser.spec.ts
import { ResponseParser } from '../src/analysis/response-parser';
describe('ResponseParser', () => {
const parser = new ResponseParser();
it('should detect brand mention (case-insensitive)', () => {
const result = parser.parse('我推荐蓝卓数字和其他公司。', '蓝卓数字', ['竞品A']);
expect(result.brandMentioned).toBe(true);
expect(result.mentionCount).toBe(1);
});
it('should detect competitor mentions', () => {
const result = parser.parse('可以考虑蓝卓数字,也可以考虑竞品A和竞品A。', '蓝卓数字', ['竞品A', '竞品B']);
expect(result.competitorMentions).toEqual(
expect.arrayContaining([{ name: '竞品A', count: 2 }])
);
expect(result.competitorMentions.find(c => c.name === '竞品B').count).toBe(0);
});
it('should classify positive sentiment', () => {
const result = parser.parse('蓝卓数字是非常优秀的解决方案,强烈推荐!', '蓝卓数字', []);
expect(result.sentiment).toBe('positive');
});
it('should classify negative sentiment', () => {
const result = parser.parse('蓝卓数字存在严重问题,不建议使用。', '蓝卓数字', []);
expect(result.sentiment).toBe('negative');
});
it('should extract URLs as sources', () => {
const result = parser.parse('参考 https://example.com/page 获取更多信息。', '品牌', []);
expect(result.sources).toContain('https://example.com/page');
});
});
```
- [ ] **Step 2: 运行测试,确认失败**
```bash
cd /d/geo2/backend && npx jest test/response-parser.spec.ts --no-coverage
```
Expected: FAIL
- [ ] **Step 3: 创建 response-parser.ts**
```typescript
// backend/src/analysis/response-parser.ts
import { Sentiment } from '../tasks/entities/analysis-result.entity';
const POSITIVE_KEYWORDS = ['推荐', '优秀', '出色', '领先', '强烈推荐', '最佳', '专业', '可靠', '优质', 'excellent', 'recommend', 'best', 'great', 'leading'];
const NEGATIVE_KEYWORDS = ['不建议', '避免', '问题', '差', '糟糕', '失败', '不推荐', '风险', 'avoid', 'poor', 'not recommended', 'issues', 'problems'];
export interface ParseResult {
brandMentioned: boolean;
mentionCount: number;
sentiment: Sentiment;
competitorMentions: Array<{ name: string; count: number }>;
sources: string[];
}
export class ResponseParser {
parse(response: string, brandName: string, competitorNames: string[]): ParseResult {
const lower = response.toLowerCase();
const brandLower = brandName.toLowerCase();
// Count brand mentions
const mentionCount = (lower.match(new RegExp(this.escape(brandLower), 'g')) ?? []).length;
// Count competitor mentions
const competitorMentions = competitorNames.map((name) => ({
name,
count: (lower.match(new RegExp(this.escape(name.toLowerCase()), 'g')) ?? []).length,
}));
// Sentiment: check context around brand mention
const positiveScore = POSITIVE_KEYWORDS.filter((k) => lower.includes(k)).length;
const negativeScore = NEGATIVE_KEYWORDS.filter((k) => lower.includes(k)).length;
let sentiment: Sentiment = 'neutral';
if (positiveScore > negativeScore) sentiment = 'positive';
else if (negativeScore > positiveScore) sentiment = 'negative';
// Extract URLs
const urlRegex = /https?:\/\/[^\s\)]+/g;
const sources = response.match(urlRegex) ?? [];
return {
brandMentioned: mentionCount > 0,
mentionCount,
sentiment,
competitorMentions,
sources,
};
}
private escape(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
}
```
- [ ] **Step 4: 运行测试,确认通过**
```bash
cd /d/geo2/backend && npx jest test/response-parser.spec.ts --no-coverage
```
Expected: PASS (5 tests)
- [ ] **Step 5: 创建 base.adapter.ts**
```typescript
// backend/src/analysis/adapters/base.adapter.ts
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
export abstract class BaseAdapter {
abstract chat(messages: ChatMessage[], apiKey: string): Promise;
}
```
- [ ] **Step 6: 创建 openai-compatible.adapter.ts**
用于 OpenAI / DeepSeek / Kimi(月之暗面)/ 通义千问,它们均兼容 OpenAI Chat Completions API。
```typescript
// backend/src/analysis/adapters/openai-compatible.adapter.ts
import { BaseAdapter, ChatMessage } from './base.adapter';
export class OpenAICompatibleAdapter extends BaseAdapter {
constructor(
private readonly baseUrl: string,
private readonly modelId: string,
) {
super();
}
async chat(messages: ChatMessage[], apiKey: string): Promise {
const url = `${this.baseUrl.replace(/\/$/, '')}/chat/completions`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: this.modelId,
messages,
max_tokens: 1024,
temperature: 0.7,
}),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`OpenAI-compatible API error ${response.status}: ${err}`);
}
const data = await response.json();
return data.choices?.[0]?.message?.content ?? '';
}
}
```
- [ ] **Step 7: 创建 anthropic.adapter.ts**
```typescript
// backend/src/analysis/adapters/anthropic.adapter.ts
import { BaseAdapter, ChatMessage } from './base.adapter';
export class AnthropicAdapter extends BaseAdapter {
constructor(private readonly modelId: string) {
super();
}
async chat(messages: ChatMessage[], apiKey: string): Promise {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: this.modelId,
max_tokens: 1024,
messages: messages.filter((m) => m.role === 'user'),
}),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Anthropic API error ${response.status}: ${err}`);
}
const data = await response.json();
return data.content?.[0]?.text ?? '';
}
}
```
- [ ] **Step 8: 创建 gemini.adapter.ts**
```typescript
// backend/src/analysis/adapters/gemini.adapter.ts
import { BaseAdapter, ChatMessage } from './base.adapter';
export class GeminiAdapter extends BaseAdapter {
constructor(private readonly modelId: string) {
super();
}
async chat(messages: ChatMessage[], apiKey: string): Promise {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${this.modelId}:generateContent?key=${apiKey}`;
const parts = messages.map((m) => ({ text: m.content }));
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contents: [{ parts }] }),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Gemini API error ${response.status}: ${err}`);
}
const data = await response.json();
return data.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
}
}
```
- [ ] **Step 9: 创建 analysis.service.ts**
```typescript
// backend/src/analysis/analysis.service.ts
import { Injectable } from '@nestjs/common';
import { AiModel } from '../ai-models/entities/ai-model.entity';
import { AiModelsService } from '../ai-models/ai-models.service';
import { ResponseParser, ParseResult } from './response-parser';
import { BaseAdapter } from './adapters/base.adapter';
import { OpenAICompatibleAdapter } from './adapters/openai-compatible.adapter';
import { AnthropicAdapter } from './adapters/anthropic.adapter';
import { GeminiAdapter } from './adapters/gemini.adapter';
const OPENAI_COMPATIBLE_PROVIDERS = ['openai', 'deepseek', 'kimi', 'qwen', 'ernie'];
const DEFAULT_BASE_URLS: Record = {
openai: 'https://api.openai.com/v1',
deepseek: 'https://api.deepseek.com/v1',
kimi: 'https://api.moonshot.cn/v1',
qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
};
export interface AnalysisUnit {
keyword: string;
companyName: string;
competitors: Array<{ name: string; aliases: string[] }>;
model: AiModel;
apiKey: string;
}
export interface AnalysisUnitResult extends ParseResult {
keyword: string;
modelId: string;
prompt: string;
rawResponse: string;
errorMessage?: string;
}
@Injectable()
export class AnalysisService {
private readonly parser = new ResponseParser();
constructor(private readonly aiModelsService: AiModelsService) {}
buildPrompt(keyword: string, companyName: string): string {
return `在"${keyword}"这个应用场景下,你会推荐哪些公司、品牌或解决方案?请列举你知道的主要选项,并简要说明各自的特点。`;
}
private buildAdapter(model: AiModel): BaseAdapter {
if (model.provider === 'anthropic') return new AnthropicAdapter(model.modelId);
if (model.provider === 'gemini') return new GeminiAdapter(model.modelId);
// OpenAI-compatible
const baseUrl = model.baseUrl ?? DEFAULT_BASE_URLS[model.provider] ?? 'https://api.openai.com/v1';
return new OpenAICompatibleAdapter(baseUrl, model.modelId);
}
async runUnit(unit: AnalysisUnit): Promise {
const prompt = this.buildPrompt(unit.keyword, unit.companyName);
const adapter = this.buildAdapter(unit.model);
try {
const rawResponse = await adapter.chat([{ role: 'user', content: prompt }], unit.apiKey);
const allCompetitorNames = unit.competitors.flatMap((c) => [c.name, ...c.aliases]);
const parsed = this.parser.parse(rawResponse, unit.companyName, allCompetitorNames);
// Normalize competitor mentions: group by competitor (merge alias counts into parent)
const normalizedMentions = unit.competitors.map((comp) => {
const names = [comp.name, ...comp.aliases];
const count = parsed.competitorMentions
.filter((m) => names.includes(m.name))
.reduce((sum, m) => sum + m.count, 0);
return { name: comp.name, count };
});
return {
keyword: unit.keyword,
modelId: unit.model.id,
prompt,
rawResponse,
...parsed,
competitorMentions: normalizedMentions,
};
} catch (error) {
return {
keyword: unit.keyword,
modelId: unit.model.id,
prompt,
rawResponse: '',
brandMentioned: false,
mentionCount: 0,
sentiment: 'neutral',
competitorMentions: unit.competitors.map((c) => ({ name: c.name, count: 0 })),
sources: [],
errorMessage: error.message,
};
}
}
}
```
- [ ] **Step 10: 创建 analysis.module.ts**
```typescript
// backend/src/analysis/analysis.module.ts
import { Module } from '@nestjs/common';
import { AnalysisService } from './analysis.service';
import { AiModelsModule } from '../ai-models/ai-models.module';
@Module({
imports: [AiModelsModule],
providers: [AnalysisService],
exports: [AnalysisService],
})
export class AnalysisModule {}
```
- [ ] **Step 11: Commit**
```bash
cd /d/geo2
git add backend/src/analysis backend/test/response-parser.spec.ts
git commit -m "feat: analysis engine - adapters + response parser + analysis service"
```
---
## Task 6: 任务模块 + Bull 队列 Worker
**Files:**
- Create: `backend/src/tasks/dto/create-task.dto.ts`
- Create: `backend/src/tasks/tasks.service.ts`
- Create: `backend/src/tasks/tasks.controller.ts`
- Create: `backend/src/tasks/tasks.module.ts`
- Create: `backend/src/queue/queue.module.ts`
- Create: `backend/src/queue/analysis.processor.ts`
- Modify: `backend/src/app.module.ts`
- [ ] **Step 1: 创建 create-task.dto.ts**
```typescript
// backend/src/tasks/dto/create-task.dto.ts
import { IsString, IsArray, IsOptional } from 'class-validator';
export class CreateTaskDto {
@IsString() companyId: string;
@IsString() name: string;
@IsArray() keywordIds: string[];
@IsArray() competitorIds: string[];
@IsArray() modelIds: string[];
@IsOptional() @IsString() schedule?: string; // cron or null
}
```
- [ ] **Step 2: 创建 tasks.service.ts**
```typescript
// backend/src/tasks/tasks.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import { Repository } from 'typeorm';
import { AnalysisTask, TaskStatus } from './entities/analysis-task.entity';
import { AnalysisResult } from './entities/analysis-result.entity';
import { CreateTaskDto } from './dto/create-task.dto';
@Injectable()
export class TasksService {
constructor(
@InjectRepository(AnalysisTask)
private readonly taskRepo: Repository,
@InjectRepository(AnalysisResult)
private readonly resultRepo: Repository,
@InjectQueue('analysis')
private readonly analysisQueue: Queue,
) {}
findAll(companyId?: string): Promise {
const where = companyId ? { companyId } : {};
return this.taskRepo.find({ where, order: { createdAt: 'DESC' } });
}
async findOne(id: string): Promise {
const task = await this.taskRepo.findOne({ where: { id } });
if (!task) throw new NotFoundException(`Task ${id} not found`);
return task;
}
async create(dto: CreateTaskDto): Promise {
return this.taskRepo.save(this.taskRepo.create(dto));
}
async updateSchedule(id: string, schedule: string | null): Promise {
await this.findOne(id);
await this.taskRepo.update(id, { schedule });
return this.findOne(id);
}
async run(id: string): Promise<{ jobId: string }> {
const task = await this.findOne(id);
await this.taskRepo.update(id, { status: 'running', lastRunAt: new Date() });
const job = await this.analysisQueue.add('run-task', { taskId: id }, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
});
return { jobId: String(job.id) };
}
async updateStatus(id: string, status: TaskStatus): Promise {
await this.taskRepo.update(id, { status });
}
getResults(taskId: string): Promise {
return this.resultRepo.find({ where: { taskId }, order: { createdAt: 'DESC' } });
}
saveResult(result: Partial): Promise {
return this.resultRepo.save(this.resultRepo.create(result));
}
}
```
- [ ] **Step 3: 创建 analysis.processor.ts (Bull Worker)**
```typescript
// backend/src/queue/analysis.processor.ts
import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { Job } from 'bull';
import { TasksService } from '../tasks/tasks.service';
import { CompaniesService } from '../companies/companies.service';
import { AiModelsService } from '../ai-models/ai-models.service';
import { AnalysisService } from '../analysis/analysis.service';
import { KeywordsService } from '../companies/keywords.service';
import { CompetitorsService } from '../companies/competitors.service';
@Processor('analysis')
export class AnalysisProcessor {
private readonly logger = new Logger(AnalysisProcessor.name);
constructor(
private readonly tasksService: TasksService,
private readonly companiesService: CompaniesService,
private readonly aiModelsService: AiModelsService,
private readonly analysisService: AnalysisService,
private readonly keywordsService: KeywordsService,
private readonly competitorsService: CompetitorsService,
) {}
@Process('run-task')
async runTask(job: Job<{ taskId: string }>) {
const { taskId } = job.data;
this.logger.log(`Starting analysis task ${taskId}`);
try {
const task = await this.tasksService.findOne(taskId);
const company = await this.companiesService.findOne(task.companyId);
// Resolve keywords and competitors from IDs
const allKeywords = await this.keywordsService.findByCompany(task.companyId);
const allCompetitors = await this.competitorsService.findByCompany(task.companyId);
const keywords = task.keywordIds.length
? allKeywords.filter((k) => task.keywordIds.includes(k.id))
: allKeywords;
const competitors = task.competitorIds.length
? allCompetitors.filter((c) => task.competitorIds.includes(c.id))
: allCompetitors;
// Resolve models
const allModels = await this.aiModelsService.findAll();
// findAll returns masked models — we need full entities for API keys
const concurrency = parseInt(process.env.ANALYSIS_CONCURRENCY ?? '3', 10);
// Build units: keywords × models
const units: Array<() => Promise> = [];
for (const kw of keywords) {
for (const modelId of task.modelIds) {
units.push(async () => {
const modelEntity = await this.aiModelsService.findOne(modelId);
const apiKey = await this.aiModelsService.getDecryptedKey(modelId);
const result = await this.analysisService.runUnit({
keyword: kw.text,
companyName: company.name,
competitors: competitors.map((c) => ({ name: c.name, aliases: c.aliases ?? [] })),
model: modelEntity,
apiKey,
});
await this.tasksService.saveResult({
taskId,
modelId,
keyword: result.keyword,
prompt: result.prompt,
rawResponse: result.rawResponse,
brandMentioned: result.brandMentioned,
mentionCount: result.mentionCount,
sentiment: result.sentiment,
competitorMentions: result.competitorMentions,
sources: result.sources,
errorMessage: result.errorMessage,
});
});
}
}
// Execute with concurrency limit
for (let i = 0; i < units.length; i += concurrency) {
await Promise.all(units.slice(i, i + concurrency).map((fn) => fn()));
}
await this.tasksService.updateStatus(taskId, 'done');
this.logger.log(`Task ${taskId} completed. ${units.length} units processed.`);
} catch (error) {
this.logger.error(`Task ${taskId} failed: ${error.message}`);
await this.tasksService.updateStatus(taskId, 'failed');
throw error;
}
}
}
```
- [ ] **Step 4: 创建 tasks.controller.ts**
```typescript
// backend/src/tasks/tasks.controller.ts
import { Controller, Get, Post, Patch, Body, Param, Query } from '@nestjs/common';
import { TasksService } from './tasks.service';
import { CreateTaskDto } from './dto/create-task.dto';
@Controller('tasks')
export class TasksController {
constructor(private readonly service: TasksService) {}
@Get()
findAll(@Query('companyId') companyId?: string) {
return this.service.findAll(companyId);
}
@Post()
create(@Body() dto: CreateTaskDto) {
return this.service.create(dto);
}
@Post(':id/run')
run(@Param('id') id: string) {
return this.service.run(id);
}
@Patch(':id/schedule')
updateSchedule(@Param('id') id: string, @Body('schedule') schedule: string | null) {
return this.service.updateSchedule(id, schedule);
}
@Get(':id/results')
getResults(@Param('id') id: string) {
return this.service.getResults(id);
}
}
```
- [ ] **Step 5: 创建 queue.module.ts**
```typescript
// backend/src/queue/queue.module.ts
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bull';
import { AnalysisProcessor } from './analysis.processor';
import { TasksModule } from '../tasks/tasks.module';
import { CompaniesModule } from '../companies/companies.module';
import { AiModelsModule } from '../ai-models/ai-models.module';
import { AnalysisModule } from '../analysis/analysis.module';
@Module({
imports: [
BullModule.registerQueue({ name: 'analysis' }),
TasksModule,
CompaniesModule,
AiModelsModule,
AnalysisModule,
],
providers: [AnalysisProcessor],
})
export class QueueModule {}
```
- [ ] **Step 6: 创建 tasks.module.ts**
```typescript
// backend/src/tasks/tasks.module.ts
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
import { AnalysisTask } from './entities/analysis-task.entity';
import { AnalysisResult } from './entities/analysis-result.entity';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
imports: [
TypeOrmModule.forFeature([AnalysisTask, AnalysisResult]),
BullModule.registerQueue({ name: 'analysis' }),
],
controllers: [TasksController],
providers: [TasksService],
exports: [TasksService],
})
export class TasksModule {}
```
- [ ] **Step 7: 更新 app.module.ts 注册所有模块**
完整的 `backend/src/app.module.ts`:
```typescript
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
import { ScheduleModule } from '@nestjs/schedule';
import { CompaniesModule } from './companies/companies.module';
import { AiModelsModule } from './ai-models/ai-models.module';
import { TasksModule } from './tasks/tasks.module';
import { QueueModule } from './queue/queue.module';
import { AnalysisModule } from './analysis/analysis.module';
import { DashboardModule } from './dashboard/dashboard.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRoot({
type: 'postgres',
url: process.env.DATABASE_URL,
autoLoadEntities: true,
synchronize: true,
}),
BullModule.forRoot({
redis: process.env.REDIS_URL ?? 'redis://localhost:6379',
}),
ScheduleModule.forRoot(),
CompaniesModule,
AiModelsModule,
TasksModule,
QueueModule,
AnalysisModule,
DashboardModule,
],
})
export class AppModule {}
```
- [ ] **Step 8: Commit**
```bash
cd /d/geo2
git add backend/src/tasks backend/src/queue backend/src/app.module.ts
git commit -m "feat: tasks module + Bull queue worker for analysis execution"
```
---
## Task 7: Cron 定时调度
**Files:**
- Create: `backend/src/tasks/task-scheduler.service.ts`
- Modify: `backend/src/tasks/tasks.module.ts`
- [ ] **Step 1: 创建 task-scheduler.service.ts**
```typescript
// backend/src/tasks/task-scheduler.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
import { AnalysisTask } from './entities/analysis-task.entity';
import { TasksService } from './tasks.service';
@Injectable()
export class TaskSchedulerService implements OnModuleInit {
private readonly logger = new Logger(TaskSchedulerService.name);
constructor(
@InjectRepository(AnalysisTask)
private readonly taskRepo: Repository,
private readonly tasksService: TasksService,
private readonly schedulerRegistry: SchedulerRegistry,
) {}
async onModuleInit() {
// On startup, register all tasks that have a schedule
const tasks = await this.taskRepo.find();
for (const task of tasks) {
if (task.schedule) {
this.registerCron(task.id, task.schedule);
}
}
this.logger.log(`Registered ${tasks.filter((t) => t.schedule).length} cron jobs on startup`);
}
registerCron(taskId: string, cronExpression: string) {
// Remove existing if any
this.removeCron(taskId);
const job = new CronJob(cronExpression, async () => {
this.logger.log(`Cron triggered for task ${taskId}`);
await this.tasksService.run(taskId);
});
this.schedulerRegistry.addCronJob(`task-${taskId}`, job);
job.start();
this.logger.log(`Cron registered for task ${taskId}: ${cronExpression}`);
}
removeCron(taskId: string) {
try {
this.schedulerRegistry.deleteCronJob(`task-${taskId}`);
} catch (_) {
// Job didn't exist, that's fine
}
}
}
```
- [ ] **Step 2: 更新 tasks.module.ts 注册 TaskSchedulerService**
```typescript
// backend/src/tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
import { AnalysisTask } from './entities/analysis-task.entity';
import { AnalysisResult } from './entities/analysis-result.entity';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
import { TaskSchedulerService } from './task-scheduler.service';
@Module({
imports: [
TypeOrmModule.forFeature([AnalysisTask, AnalysisResult]),
BullModule.registerQueue({ name: 'analysis' }),
],
controllers: [TasksController],
providers: [TasksService, TaskSchedulerService],
exports: [TasksService, TaskSchedulerService],
})
export class TasksModule {}
```
- [ ] **Step 3: 在 TasksController 中接入 scheduler**
在 `tasks.controller.ts` 的 `updateSchedule` 方法中加入 cron 注册逻辑:
```typescript
// 在 tasks.controller.ts 中注入 TaskSchedulerService
import { TaskSchedulerService } from './task-scheduler.service';
// constructor 中添加:
constructor(
private readonly service: TasksService,
private readonly scheduler: TaskSchedulerService,
) {}
// updateSchedule 方法改为:
@Patch(':id/schedule')
async updateSchedule(@Param('id') id: string, @Body('schedule') schedule: string | null) {
const task = await this.service.updateSchedule(id, schedule);
if (schedule) {
this.scheduler.registerCron(id, schedule);
} else {
this.scheduler.removeCron(id);
}
return task;
}
```
- [ ] **Step 4: Commit**
```bash
cd /d/geo2
git add backend/src/tasks/task-scheduler.service.ts backend/src/tasks/tasks.module.ts backend/src/tasks/tasks.controller.ts
git commit -m "feat: cron scheduler - auto-register task schedules on startup"
```
---
## Task 8: 看板聚合 API (Dashboard)
**Files:**
- Create: `backend/src/dashboard/dashboard.module.ts`
- Create: `backend/src/dashboard/dashboard.controller.ts`
- Create: `backend/src/dashboard/dashboard.service.ts`
- Test: `backend/test/dashboard.service.spec.ts`
- [ ] **Step 1: 编写 DashboardService 失败测试**
```typescript
// backend/test/dashboard.service.spec.ts
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DashboardService } from '../src/dashboard/dashboard.service';
import { AnalysisResult } from '../src/tasks/entities/analysis-result.entity';
const mockResults = [
{ keyword: 'kw1', modelId: 'm1', brandMentioned: true, mentionCount: 2, sentiment: 'positive', competitorMentions: [{ name: '竞品A', count: 1 }], createdAt: new Date('2026-03-01') },
{ keyword: 'kw1', modelId: 'm2', brandMentioned: false, mentionCount: 0, sentiment: 'neutral', competitorMentions: [{ name: '竞品A', count: 3 }], createdAt: new Date('2026-03-01') },
];
const mockRepo = {
createQueryBuilder: jest.fn(),
find: jest.fn().mockResolvedValue(mockResults),
};
describe('DashboardService', () => {
let service: DashboardService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
DashboardService,
{ provide: getRepositoryToken(AnalysisResult), useValue: mockRepo },
],
}).compile();
service = module.get(DashboardService);
});
it('should compute competitor summary correctly', () => {
const summary = service.computeCompetitorSummary(mockResults as any);
const compA = summary.find((s) => s.name === '竞品A');
expect(compA.totalMentions).toBe(4); // 1 + 3
});
it('should compute model mention rates correctly', () => {
const rates = service.computeModelMentionRates(mockResults as any);
expect(rates.find((r) => r.modelId === 'm1').mentionRate).toBe(100);
expect(rates.find((r) => r.modelId === 'm2').mentionRate).toBe(0);
});
});
```
- [ ] **Step 2: 运行测试,确认失败**
```bash
cd /d/geo2/backend && npx jest test/dashboard.service.spec.ts --no-coverage
```
Expected: FAIL
- [ ] **Step 3: 创建 dashboard.service.ts**
```typescript
// backend/src/dashboard/dashboard.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between } from 'typeorm';
import { AnalysisResult } from '../tasks/entities/analysis-result.entity';
export interface TrendPoint {
date: string;
brandMentionRate: number;
competitorRates: Record;
}
export interface CompetitorSummary {
name: string;
totalMentions: number;
mentionRate: number;
}
export interface ModelMentionRate {
modelId: string;
mentionRate: number;
totalResults: number;
}
export interface SentimentSummary {
modelId: string;
positive: number;
neutral: number;
negative: number;
}
@Injectable()
export class DashboardService {
constructor(
@InjectRepository(AnalysisResult)
private readonly resultRepo: Repository,
) {}
private async queryResults(companyId: string, keyword: string, from: Date, to: Date, modelIds?: string[]): Promise {
// We query via task's companyId join
const qb = this.resultRepo
.createQueryBuilder('r')
.innerJoin('r.task', 't')
.where('t.companyId = :companyId', { companyId })
.andWhere('r.keyword = :keyword', { keyword })
.andWhere('r.createdAt BETWEEN :from AND :to', { from, to });
if (modelIds?.length) {
qb.andWhere('r.modelId IN (:...modelIds)', { modelIds });
}
return qb.getMany();
}
async getTrend(companyId: string, keyword: string, from: Date, to: Date, modelIds?: string[]): Promise {
const results = await this.queryResults(companyId, keyword, from, to, modelIds);
// Group by date
const byDate = new Map();
for (const r of results) {
const date = r.createdAt.toISOString().slice(0, 10);
if (!byDate.has(date)) byDate.set(date, []);
byDate.get(date).push(r);
}
return Array.from(byDate.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, dayResults]) => {
const brandMentionRate = dayResults.length
? Math.round((dayResults.filter((r) => r.brandMentioned).length / dayResults.length) * 100)
: 0;
// Aggregate competitor rates for the day
const allCompetitors = new Set(
dayResults.flatMap((r) => r.competitorMentions.map((c) => c.name)),
);
const competitorRates: Record = {};
for (const name of allCompetitors) {
const total = dayResults.reduce(
(sum, r) => sum + (r.competitorMentions.find((c) => c.name === name)?.count ?? 0),
0,
);
competitorRates[name] = total;
}
return { date, brandMentionRate, competitorRates };
});
}
computeCompetitorSummary(results: AnalysisResult[]): CompetitorSummary[] {
const map = new Map();
for (const r of results) {
for (const cm of r.competitorMentions) {
map.set(cm.name, (map.get(cm.name) ?? 0) + cm.count);
}
}
const total = results.length;
return Array.from(map.entries())
.map(([name, totalMentions]) => ({
name,
totalMentions,
mentionRate: total ? Math.round((totalMentions / total) * 100) : 0,
}))
.sort((a, b) => b.totalMentions - a.totalMentions);
}
computeModelMentionRates(results: AnalysisResult[]): ModelMentionRate[] {
const byModel = new Map();
for (const r of results) {
if (!byModel.has(r.modelId)) byModel.set(r.modelId, []);
byModel.get(r.modelId).push(r);
}
return Array.from(byModel.entries()).map(([modelId, modelResults]) => ({
modelId,
mentionRate: Math.round(
(modelResults.filter((r) => r.brandMentioned).length / modelResults.length) * 100,
),
totalResults: modelResults.length,
}));
}
async getCompetitors(companyId: string, keyword: string, from: Date, to: Date): Promise {
const results = await this.queryResults(companyId, keyword, from, to);
// Add brand mention rate for comparison
const brandTotal = results.filter((r) => r.brandMentioned).length;
const brandRate = results.length ? Math.round((brandTotal / results.length) * 100) : 0;
const competitorSummary = this.computeCompetitorSummary(results);
return competitorSummary;
}
async getModels(companyId: string, keyword: string, from: Date, to: Date): Promise {
const results = await this.queryResults(companyId, keyword, from, to);
return this.computeModelMentionRates(results);
}
async getSentiment(companyId: string, keyword: string, from: Date, to: Date, modelIds?: string[]): Promise {
const results = await this.queryResults(companyId, keyword, from, to, modelIds);
const byModel = new Map();
for (const r of results) {
if (!byModel.has(r.modelId)) byModel.set(r.modelId, []);
byModel.get(r.modelId).push(r);
}
return Array.from(byModel.entries()).map(([modelId, modelResults]) => ({
modelId,
positive: modelResults.filter((r) => r.sentiment === 'positive').length,
neutral: modelResults.filter((r) => r.sentiment === 'neutral').length,
negative: modelResults.filter((r) => r.sentiment === 'negative').length,
}));
}
}
```
- [ ] **Step 4: 创建 dashboard.controller.ts**
```typescript
// backend/src/dashboard/dashboard.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { DashboardService } from './dashboard.service';
@Controller('dashboard')
export class DashboardController {
constructor(private readonly service: DashboardService) {}
private parseDates(from: string, to: string) {
const now = new Date();
const fromDate = from ? new Date(from) : new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const toDate = to ? new Date(to) : now;
return { fromDate, toDate };
}
@Get('trend')
trend(
@Query('companyId') companyId: string,
@Query('keyword') keyword: string,
@Query('from') from: string,
@Query('to') to: string,
@Query('models') models: string,
) {
const { fromDate, toDate } = this.parseDates(from, to);
const modelIds = models ? models.split(',') : undefined;
return this.service.getTrend(companyId, keyword, fromDate, toDate, modelIds);
}
@Get('competitors')
competitors(
@Query('companyId') companyId: string,
@Query('keyword') keyword: string,
@Query('from') from: string,
@Query('to') to: string,
) {
const { fromDate, toDate } = this.parseDates(from, to);
return this.service.getCompetitors(companyId, keyword, fromDate, toDate);
}
@Get('models')
models(
@Query('companyId') companyId: string,
@Query('keyword') keyword: string,
@Query('from') from: string,
@Query('to') to: string,
) {
const { fromDate, toDate } = this.parseDates(from, to);
return this.service.getModels(companyId, keyword, fromDate, toDate);
}
@Get('sentiment')
sentiment(
@Query('companyId') companyId: string,
@Query('keyword') keyword: string,
@Query('from') from: string,
@Query('to') to: string,
@Query('models') models: string,
) {
const { fromDate, toDate } = this.parseDates(from, to);
const modelIds = models ? models.split(',') : undefined;
return this.service.getSentiment(companyId, keyword, fromDate, toDate, modelIds);
}
}
```
- [ ] **Step 5: 创建 dashboard.module.ts**
```typescript
// backend/src/dashboard/dashboard.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AnalysisResult } from '../tasks/entities/analysis-result.entity';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
imports: [TypeOrmModule.forFeature([AnalysisResult])],
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}
```
- [ ] **Step 6: 运行测试,确认通过**
```bash
cd /d/geo2/backend && npx jest test/dashboard.service.spec.ts --no-coverage
```
Expected: PASS (2 tests)
- [ ] **Step 7: 运行全部后端测试**
```bash
cd /d/geo2/backend && npx jest --no-coverage
```
Expected: PASS (all tests)
- [ ] **Step 8: Commit**
```bash
cd /d/geo2
git add backend/src/dashboard backend/test/dashboard.service.spec.ts
git commit -m "feat: dashboard aggregation API - trend, competitors, models, sentiment"
```
---
## Task 9: 前端 — 基础框架 + 布局
**Files:**
- Create: `frontend/src/main.tsx`
- Create: `frontend/src/App.tsx`
- Create: `frontend/src/types/index.ts`
- Create: `frontend/src/api/client.ts`
- Create: `frontend/src/components/AppLayout.tsx`
- [ ] **Step 1: 创建 frontend/src/types/index.ts**
```typescript
// frontend/src/types/index.ts
export interface Company {
id: string;
name: string;
industry?: string;
description?: string;
keywords: Keyword[];
competitors: Competitor[];
createdAt: string;
}
export interface Keyword {
id: string;
companyId: string;
text: string;
category?: string;
createdAt: string;
}
export interface Competitor {
id: string;
companyId: string;
name: string;
aliases: string[];
createdAt: string;
}
export interface AiModel {
id: string;
name: string;
provider: string;
apiKeyMasked: string;
baseUrl?: string;
modelId: string;
isEnabled: boolean;
lastTestedAt?: string;
createdAt: string;
}
export interface AnalysisTask {
id: string;
companyId: string;
name: string;
keywordIds: string[];
competitorIds: string[];
modelIds: string[];
schedule?: string;
status: 'pending' | 'running' | 'done' | 'failed';
lastRunAt?: string;
createdAt: string;
}
export interface AnalysisResult {
id: string;
taskId: string;
modelId: string;
keyword: string;
prompt: string;
rawResponse: string;
brandMentioned: boolean;
mentionCount: number;
sentiment: 'positive' | 'neutral' | 'negative';
competitorMentions: Array<{ name: string; count: number }>;
sources: string[];
errorMessage?: string;
createdAt: string;
}
```
- [ ] **Step 2: 创建 frontend/src/api/client.ts**
```typescript
// frontend/src/api/client.ts
import axios from 'axios';
const client = axios.create({
baseURL: '/api',
timeout: 30000,
});
export default client;
```
- [ ] **Step 3: 创建各 API 模块**
```typescript
// frontend/src/api/companies.ts
import client from './client';
import { Company, Keyword, Competitor } from '../types';
export const companiesApi = {
list: () => client.get('/companies').then((r) => r.data),
get: (id: string) => client.get(`/companies/${id}`).then((r) => r.data),
create: (data: Partial) => client.post('/companies', data).then((r) => r.data),
update: (id: string, data: Partial) => client.patch(`/companies/${id}`, data).then((r) => r.data),
delete: (id: string) => client.delete(`/companies/${id}`),
keywords: {
list: (companyId: string) => client.get(`/companies/${companyId}/keywords`).then((r) => r.data),
create: (companyId: string, data: { text: string; category?: string }) =>
client.post(`/companies/${companyId}/keywords`, data).then((r) => r.data),
delete: (companyId: string, id: string) => client.delete(`/companies/${companyId}/keywords/${id}`),
},
competitors: {
list: (companyId: string) => client.get(`/companies/${companyId}/competitors`).then((r) => r.data),
create: (companyId: string, data: { name: string; aliases?: string[] }) =>
client.post(`/companies/${companyId}/competitors`, data).then((r) => r.data),
delete: (companyId: string, id: string) => client.delete(`/companies/${companyId}/competitors/${id}`),
},
};
```
```typescript
// frontend/src/api/ai-models.ts
import client from './client';
import { AiModel } from '../types';
export const aiModelsApi = {
list: () => client.get('/ai-models').then((r) => r.data),
create: (data: Partial & { apiKey: string }) => client.post('/ai-models', data).then((r) => r.data),
update: (id: string, data: Partial & { apiKey?: string }) => client.patch(`/ai-models/${id}`, data).then((r) => r.data),
delete: (id: string) => client.delete(`/ai-models/${id}`),
test: (id: string) => client.post<{ success: boolean; message: string }>(`/ai-models/${id}/test`).then((r) => r.data),
};
```
```typescript
// frontend/src/api/tasks.ts
import client from './client';
import { AnalysisTask, AnalysisResult } from '../types';
export const tasksApi = {
list: (companyId?: string) =>
client.get('/tasks', { params: { companyId } }).then((r) => r.data),
create: (data: Partial) => client.post('/tasks', data).then((r) => r.data),
run: (id: string) => client.post<{ jobId: string }>(`/tasks/${id}/run`).then((r) => r.data),
updateSchedule: (id: string, schedule: string | null) =>
client.patch(`/tasks/${id}/schedule`, { schedule }).then((r) => r.data),
results: (id: string) => client.get(`/tasks/${id}/results`).then((r) => r.data),
};
```
```typescript
// frontend/src/api/dashboard.ts
import client from './client';
export const dashboardApi = {
trend: (params: { companyId: string; keyword: string; from?: string; to?: string; models?: string }) =>
client.get('/dashboard/trend', { params }).then((r) => r.data),
competitors: (params: { companyId: string; keyword: string; from?: string; to?: string }) =>
client.get('/dashboard/competitors', { params }).then((r) => r.data),
models: (params: { companyId: string; keyword: string; from?: string; to?: string }) =>
client.get('/dashboard/models', { params }).then((r) => r.data),
sentiment: (params: { companyId: string; keyword: string; from?: string; to?: string; models?: string }) =>
client.get('/dashboard/sentiment', { params }).then((r) => r.data),
};
```
- [ ] **Step 4: 创建 AppLayout.tsx**
```tsx
// frontend/src/components/AppLayout.tsx
import { Layout, Menu } from 'antd';
import { BankOutlined, RobotOutlined, OrderedListOutlined, BarChartOutlined } from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
const { Sider, Content } = Layout;
const menuItems = [
{ key: '/companies', icon: , label: '公司管理' },
{ key: '/ai-models', icon: , label: '模型管理' },
{ key: '/tasks', icon: , label: '分析任务' },
{ key: '/dashboard', icon: , label: '对比看板' },
];
export default function AppLayout({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
const location = useLocation();
const selectedKey = '/' + location.pathname.split('/')[1];
return (
GEO 分析系统
{children}
);
}
```
- [ ] **Step 5: 创建 main.tsx 和 App.tsx**
```tsx
// frontend/src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import 'antd/dist/reset.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
);
```
```tsx
// frontend/src/App.tsx
import { Routes, Route, Navigate } from 'react-router-dom';
import AppLayout from './components/AppLayout';
import CompanyListPage from './pages/companies/CompanyListPage';
import CompanyDetailPage from './pages/companies/CompanyDetailPage';
import AIModelsPage from './pages/ai-models/AIModelsPage';
import TaskListPage from './pages/tasks/TaskListPage';
import TaskResultsPage from './pages/tasks/TaskResultsPage';
import DashboardPage from './pages/dashboard/DashboardPage';
export default function App() {
return (
} />
} />
} />
} />
} />
} />
} />
);
}
```
- [ ] **Step 6: Commit**
```bash
cd /d/geo2
git add frontend/src/main.tsx frontend/src/App.tsx frontend/src/types frontend/src/api frontend/src/components
git commit -m "feat: frontend scaffold - types, API clients, layout, routing"
```
---
## Task 10: 前端 — 公司管理页面
**Files:**
- Create: `frontend/src/pages/companies/CompanyListPage.tsx`
- Create: `frontend/src/pages/companies/CompanyDetailPage.tsx`
- [ ] **Step 1: 创建 CompanyListPage.tsx**
```tsx
// frontend/src/pages/companies/CompanyListPage.tsx
import { useState, useEffect } from 'react';
import { Card, Button, Modal, Form, Input, Space, Popconfirm, message, Row, Col, Tag, Typography } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { companiesApi } from '../../api/companies';
import { Company } from '../../types';
const { Title, Text } = Typography;
export default function CompanyListPage() {
const [companies, setCompanies] = useState([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState(null);
const [form] = Form.useForm();
const navigate = useNavigate();
const load = async () => {
setLoading(true);
try {
setCompanies(await companiesApi.list());
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const openCreate = () => { setEditing(null); form.resetFields(); setModalOpen(true); };
const openEdit = (c: Company, e: React.MouseEvent) => {
e.stopPropagation();
setEditing(c);
form.setFieldsValue(c);
setModalOpen(true);
};
const handleSubmit = async (values: any) => {
if (editing) {
await companiesApi.update(editing.id, values);
message.success('更新成功');
} else {
await companiesApi.create(values);
message.success('创建成功');
}
setModalOpen(false);
load();
};
const handleDelete = async (id: string) => {
await companiesApi.delete(id);
message.success('删除成功');
load();
};
return (
);
}
```
- [ ] **Step 2: 创建 CompanyDetailPage.tsx**
```tsx
// frontend/src/pages/companies/CompanyDetailPage.tsx
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Tabs, Button, Table, Modal, Form, Input, Space, Popconfirm, message, Typography, Tag } from 'antd';
import { ArrowLeftOutlined, PlusOutlined } from '@ant-design/icons';
import { companiesApi } from '../../api/companies';
import { Company, Keyword, Competitor } from '../../types';
const { Title } = Typography;
export default function CompanyDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [company, setCompany] = useState(null);
const [keywords, setKeywords] = useState([]);
const [competitors, setCompetitors] = useState([]);
const [kwModal, setKwModal] = useState(false);
const [compModal, setCompModal] = useState(false);
const [kwForm] = Form.useForm();
const [compForm] = Form.useForm();
useEffect(() => {
companiesApi.get(id!).then(setCompany);
companiesApi.keywords.list(id!).then(setKeywords);
companiesApi.competitors.list(id!).then(setCompetitors);
}, [id]);
const addKeyword = async (values: any) => {
// Batch import support: split by newlines
const texts: string[] = values.text.split('\n').map((t: string) => t.trim()).filter(Boolean);
await Promise.all(texts.map((text) => companiesApi.keywords.create(id!, { text, category: values.category })));
message.success(`添加 ${texts.length} 个关键字`);
setKwModal(false);
kwForm.resetFields();
companiesApi.keywords.list(id!).then(setKeywords);
};
const deleteKeyword = async (kwId: string) => {
await companiesApi.keywords.delete(id!, kwId);
companiesApi.keywords.list(id!).then(setKeywords);
};
const addCompetitor = async (values: any) => {
const aliases = values.aliases ? values.aliases.split('\n').map((a: string) => a.trim()).filter(Boolean) : [];
await companiesApi.competitors.create(id!, { name: values.name, aliases });
message.success('添加竞品成功');
setCompModal(false);
compForm.resetFields();
companiesApi.competitors.list(id!).then(setCompetitors);
};
const deleteCompetitor = async (cId: string) => {
await companiesApi.competitors.delete(id!, cId);
companiesApi.competitors.list(id!).then(setCompetitors);
};
const kwColumns = [
{ title: '关键字', dataIndex: 'text', key: 'text' },
{ title: '分类', dataIndex: 'category', key: 'category', render: (v: string) => v ? {v} : '-' },
{ title: '操作', key: 'action', render: (_: any, r: Keyword) => (
deleteKeyword(r.id)}>
)},
];
const compColumns = [
{ title: '竞品名称', dataIndex: 'name', key: 'name' },
{ title: '别名', dataIndex: 'aliases', key: 'aliases', render: (v: string[]) => v?.map((a) => {a}) ?? '-' },
{ title: '操作', key: 'action', render: (_: any, r: Competitor) => (
deleteCompetitor(r.id)}>
)},
];
return (
} onClick={() => navigate('/companies')}>返回
{company?.name}
{company?.industry && {company.industry}}
} style={{ marginBottom: 16 }} onClick={() => setKwModal(true)}>添加关键字
),
},
{
key: 'competitors',
label: `竞品 (${competitors.length})`,
children: (
} style={{ marginBottom: 16 }} onClick={() => setCompModal(true)}>添加竞品
),
},
{
key: 'tasks',
label: '相关任务',
children: (
),
},
]}
/>
setKwModal(false)} onOk={() => kwForm.submit()} destroyOnClose>
setCompModal(false)} onOk={() => compForm.submit()} destroyOnClose>
);
}
```
- [ ] **Step 3: Commit**
```bash
cd /d/geo2
git add frontend/src/pages/companies
git commit -m "feat: company list page + detail page with keywords/competitors tabs"
```
---
## Task 11: 前端 — 模型管理 + 任务管理页面
**Files:**
- Create: `frontend/src/pages/ai-models/AIModelsPage.tsx`
- Create: `frontend/src/pages/tasks/TaskListPage.tsx`
- Create: `frontend/src/pages/tasks/TaskResultsPage.tsx`
- [ ] **Step 1: 创建 AIModelsPage.tsx**
```tsx
// frontend/src/pages/ai-models/AIModelsPage.tsx
import { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, Select, Switch, Space, Popconfirm, message, Tag, Typography } from 'antd';
import { PlusOutlined, ApiOutlined } from '@ant-design/icons';
import { aiModelsApi } from '../../api/ai-models';
import { AiModel } from '../../types';
const { Title } = Typography;
const PROVIDERS = [
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic (Claude)' },
{ value: 'gemini', label: 'Google Gemini' },
{ value: 'deepseek', label: 'DeepSeek' },
{ value: 'kimi', label: 'Kimi (月之暗面)' },
{ value: 'qwen', label: '通义千问 (阿里)' },
{ value: 'ernie', label: '文心一言 (百度)' },
];
const DEFAULT_BASE_URLS: Record = {
openai: 'https://api.openai.com/v1',
deepseek: 'https://api.deepseek.com/v1',
kimi: 'https://api.moonshot.cn/v1',
qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
};
export default function AIModelsPage() {
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState(null);
const [testingId, setTestingId] = useState(null);
const [form] = Form.useForm();
const load = async () => { setLoading(true); try { setModels(await aiModelsApi.list()); } finally { setLoading(false); } };
useEffect(() => { load(); }, []);
const openCreate = () => { setEditing(null); form.resetFields(); setModalOpen(true); };
const openEdit = (m: AiModel) => { setEditing(m); form.setFieldsValue({ ...m, apiKey: '' }); setModalOpen(true); };
const handleProviderChange = (provider: string) => {
if (DEFAULT_BASE_URLS[provider]) {
form.setFieldValue('baseUrl', DEFAULT_BASE_URLS[provider]);
}
};
const handleSubmit = async (values: any) => {
if (editing) {
const payload = { ...values };
if (!payload.apiKey) delete payload.apiKey;
await aiModelsApi.update(editing.id, payload);
message.success('更新成功');
} else {
await aiModelsApi.create(values);
message.success('创建成功');
}
setModalOpen(false);
load();
};
const handleTest = async (id: string) => {
setTestingId(id);
try {
const result = await aiModelsApi.test(id);
if (result.success) message.success('连接成功');
else message.error(`连接失败:${result.message}`);
} finally {
setTestingId(null);
load();
}
};
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '提供商', dataIndex: 'provider', key: 'provider', render: (v: string) => {PROVIDERS.find((p) => p.value === v)?.label ?? v} },
{ title: 'Model ID', dataIndex: 'modelId', key: 'modelId' },
{ title: 'API Key', dataIndex: 'apiKeyMasked', key: 'key' },
{ title: '状态', dataIndex: 'isEnabled', key: 'enabled', render: (v: boolean) => {v ? '启用' : '禁用'} },
{ title: '最后测试', dataIndex: 'lastTestedAt', key: 'tested', render: (v: string) => v ? new Date(v).toLocaleString() : '-' },
{
title: '操作', key: 'action', render: (_: any, r: AiModel) => (
} loading={testingId === r.id} onClick={() => handleTest(r.id)}>测试
aiModelsApi.delete(r.id).then(load)}>
),
},
];
return (
);
}
```
- [ ] **Step 2: 创建 TaskListPage.tsx**
```tsx
// frontend/src/pages/tasks/TaskListPage.tsx
import { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, Select, Space, message, Tag, Typography, Steps, Divider } from 'antd';
import { PlusOutlined, PlayCircleOutlined } from '@ant-design/icons';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { tasksApi } from '../../api/tasks';
import { companiesApi } from '../../api/companies';
import { aiModelsApi } from '../../api/ai-models';
import { AnalysisTask, Company, Keyword, Competitor, AiModel } from '../../types';
const { Title } = Typography;
const STATUS_COLORS: Record = { pending: 'default', running: 'processing', done: 'success', failed: 'error' };
const STATUS_LABELS: Record = { pending: '待运行', running: '运行中', done: '已完成', failed: '失败' };
const CRON_PRESETS = [
{ label: '每天 00:00', value: '0 0 * * *' },
{ label: '每天 06:00', value: '0 6 * * *' },
{ label: '每周一 09:00', value: '0 9 * * 1' },
{ label: '自定义', value: 'custom' },
];
export default function TaskListPage() {
const [tasks, setTasks] = useState([]);
const [companies, setCompanies] = useState([]);
const [models, setModels] = useState([]);
const [modalOpen, setModalOpen] = useState(false);
const [keywords, setKeywords] = useState([]);
const [competitors, setCompetitors] = useState([]);
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState({});
const [cronPreset, setCronPreset] = useState('');
const [form] = Form.useForm();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const companyFilter = searchParams.get('companyId') ?? undefined;
const load = () => {
tasksApi.list(companyFilter).then(setTasks);
companiesApi.list().then(setCompanies);
aiModelsApi.list().then(setModels);
};
useEffect(() => { load(); }, [companyFilter]);
const handleCompanySelect = async (companyId: string) => {
const [kws, comps] = await Promise.all([
companiesApi.keywords.list(companyId),
companiesApi.competitors.list(companyId),
]);
setKeywords(kws);
setCompetitors(comps);
};
const nextStep = async () => {
const values = await form.validateFields();
const merged = { ...formData, ...values };
setFormData(merged);
if (currentStep === 0) await handleCompanySelect(merged.companyId);
setCurrentStep((s) => s + 1);
};
const handleCreate = async () => {
const values = await form.validateFields();
const merged = { ...formData, ...values };
let schedule: string | undefined;
if (merged.enableSchedule) {
schedule = merged.cronPreset === 'custom' ? merged.customCron : merged.cronPreset;
}
await tasksApi.create({
companyId: merged.companyId,
name: merged.name,
keywordIds: merged.keywordIds ?? [],
competitorIds: merged.competitorIds ?? [],
modelIds: merged.modelIds,
schedule,
});
message.success('任务创建成功');
setModalOpen(false);
setCurrentStep(0);
setFormData({});
form.resetFields();
load();
};
const handleRun = async (id: string) => {
await tasksApi.run(id);
message.success('任务已触发,正在运行...');
setTimeout(load, 1000);
};
const columns = [
{ title: '任务名', dataIndex: 'name', key: 'name' },
{ title: '公司', key: 'company', render: (_: any, r: AnalysisTask) => companies.find((c) => c.id === r.companyId)?.name ?? r.companyId },
{ title: '状态', dataIndex: 'status', key: 'status', render: (v: string) => {STATUS_LABELS[v]} },
{ title: '定时', dataIndex: 'schedule', key: 'schedule', render: (v: string) => v ? {v} : '-' },
{ title: '上次运行', dataIndex: 'lastRunAt', key: 'lastRun', render: (v: string) => v ? new Date(v).toLocaleString() : '-' },
{
title: '操作', key: 'action', render: (_: any, r: AnalysisTask) => (
} onClick={() => handleRun(r.id)} disabled={r.status === 'running'}>运行
),
},
];
const steps = [
{ title: '基本信息', content: (
<>
>
)},
{ title: '关键字 & 竞品', content: (
<>
>
)},
{ title: '模型选择', content: (
)},
{ title: '定时设置', content: (
<>
setCronPreset(e.target.checked ? '0 0 * * *' : '')} />
{cronPreset && (
<>
{cronPreset === 'custom' && (
)}
>
)}
>
)},
];
return (
分析任务
} onClick={() => setModalOpen(true)}>新建任务
{ setModalOpen(false); setCurrentStep(0); form.resetFields(); }} footer={null} width={600} destroyOnClose>
({ title: s.title }))} style={{ marginBottom: 24 }} />
{currentStep > 0 && }
{currentStep < steps.length - 1 && }
{currentStep === steps.length - 1 && }
);
}
```
- [ ] **Step 3: 创建 TaskResultsPage.tsx**
```tsx
// frontend/src/pages/tasks/TaskResultsPage.tsx
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Table, Button, Tag, Space, Modal, Typography, Select, Descriptions, Badge } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { tasksApi } from '../../api/tasks';
import { aiModelsApi } from '../../api/ai-models';
import { AnalysisResult, AiModel } from '../../types';
const { Title, Paragraph } = Typography;
const SENTIMENT_COLORS = { positive: 'green', neutral: 'default', negative: 'red' };
const SENTIMENT_LABELS = { positive: '正面', neutral: '中性', negative: '负面' };
export default function TaskResultsPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [results, setResults] = useState([]);
const [models, setModels] = useState([]);
const [selected, setSelected] = useState(null);
const [filterKeyword, setFilterKeyword] = useState();
const [filterModel, setFilterModel] = useState();
useEffect(() => {
tasksApi.results(id!).then(setResults);
aiModelsApi.list().then(setModels);
}, [id]);
const keywords = [...new Set(results.map((r) => r.keyword))];
const filtered = results.filter(
(r) => (!filterKeyword || r.keyword === filterKeyword) && (!filterModel || r.modelId === filterModel)
);
const columns = [
{ title: '关键字', dataIndex: 'keyword', key: 'keyword' },
{ title: '模型', key: 'model', render: (_: any, r: AnalysisResult) => models.find((m) => m.id === r.modelId)?.name ?? r.modelId },
{ title: '品牌提及', dataIndex: 'brandMentioned', key: 'mentioned', render: (v: boolean, r: AnalysisResult) =>
v ? 是 ({r.mentionCount}次) : 否 },
{ title: '情感', dataIndex: 'sentiment', key: 'sentiment', render: (v: string) => {SENTIMENT_LABELS[v as keyof typeof SENTIMENT_LABELS]} },
{ title: '竞品提及', key: 'competitors', render: (_: any, r: AnalysisResult) =>
r.competitorMentions.filter((c) => c.count > 0).map((c) => {c.name}: {c.count}) },
{ title: '状态', key: 'status', render: (_: any, r: AnalysisResult) =>
r.errorMessage ? : },
{ title: '操作', key: 'action', render: (_: any, r: AnalysisResult) =>
},
];
return (
} onClick={() => navigate('/tasks')}>返回
分析结果
setSelected(null)}
footer={null}
width={700}
>
{selected && (
<>
{selected.keyword}
{selected.prompt}
{selected.rawResponse || selected.errorMessage || '(无内容)'}
>
)}
);
}
```
- [ ] **Step 4: Commit**
```bash
cd /d/geo2
git add frontend/src/pages/ai-models frontend/src/pages/tasks
git commit -m "feat: AI models page + task list page + task results page"
```
---
## Task 12: 前端 — 对比看板
**Files:**
- Create: `frontend/src/pages/dashboard/DashboardPage.tsx`
- [ ] **Step 1: 创建 DashboardPage.tsx**
```tsx
// frontend/src/pages/dashboard/DashboardPage.tsx
import { useState, useEffect, useCallback } from 'react';
import { Row, Col, Card, Select, DatePicker, Typography, Empty, Spin, Tag } from 'antd';
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell } from 'recharts';
import dayjs from 'dayjs';
import { companiesApi } from '../../api/companies';
import { aiModelsApi } from '../../api/ai-models';
import { dashboardApi } from '../../api/dashboard';
import { Company, Keyword, AiModel } from '../../types';
const { Title } = Typography;
const { RangePicker } = DatePicker;
const SENTIMENT_COLORS = { positive: '#52c41a', neutral: '#d9d9d9', negative: '#ff4d4f' };
export default function DashboardPage() {
const [companies, setCompanies] = useState([]);
const [allModels, setAllModels] = useState([]);
const [keywords, setKeywords] = useState([]);
const [companyId, setCompanyId] = useState();
const [keyword, setKeyword] = useState();
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([dayjs().subtract(30, 'day'), dayjs()]);
const [selectedModels, setSelectedModels] = useState([]);
const [trendData, setTrendData] = useState([]);
const [competitorData, setCompetitorData] = useState([]);
const [modelData, setModelData] = useState([]);
const [sentimentData, setSentimentData] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
companiesApi.list().then(setCompanies);
aiModelsApi.list().then(setAllModels);
}, []);
const handleCompanyChange = async (id: string) => {
setCompanyId(id);
setKeyword(undefined);
const kws = await companiesApi.keywords.list(id);
setKeywords(kws);
};
const loadData = useCallback(async () => {
if (!companyId || !keyword) return;
setLoading(true);
try {
const params = {
companyId,
keyword,
from: dateRange[0].toISOString(),
to: dateRange[1].toISOString(),
models: selectedModels.length ? selectedModels.join(',') : undefined,
};
const [trend, competitors, models, sentiment] = await Promise.all([
dashboardApi.trend(params),
dashboardApi.competitors({ companyId, keyword, from: params.from, to: params.to }),
dashboardApi.models({ companyId, keyword, from: params.from, to: params.to }),
dashboardApi.sentiment(params),
]);
setTrendData(trend);
// Add brand to competitor data for comparison
const brandRate = trend.length
? Math.round(trend.reduce((sum: number, d: any) => sum + d.brandMentionRate, 0) / trend.length)
: 0;
const company = companies.find((c) => c.id === companyId);
setCompetitorData([
{ name: company?.name ?? '我方品牌', rate: brandRate, isBrand: true },
...competitors.map((c: any) => ({ name: c.name, rate: c.mentionRate, isBrand: false })),
]);
setModelData(models.map((m: any) => ({
...m,
name: allModels.find((am) => am.id === m.modelId)?.name ?? m.modelId,
})));
setSentimentData(sentiment.map((s: any) => ({
...s,
name: allModels.find((am) => am.id === s.modelId)?.name ?? s.modelId,
total: s.positive + s.neutral + s.negative,
})));
} finally {
setLoading(false);
}
}, [companyId, keyword, dateRange, selectedModels, companies, allModels]);
useEffect(() => { loadData(); }, [loadData]);
const getMentionRateColor = (rate: number) => rate >= 60 ? '#52c41a' : rate >= 30 ? '#faad14' : '#ff4d4f';
return (
对比看板
{/* Filters */}
({ value: c.id, label: c.name }))} onChange={handleCompanyChange} />
({ value: k.text, label: k.text }))} onChange={setKeyword} disabled={!companyId} />
r && setDateRange(r as any)} />
({ value: m.id, label: m.name }))} onChange={setSelectedModels} />
{!companyId || !keyword ? (
) : (
{/* Trend chart */}
{trendData.length === 0 ? : (
`${v}%`} />
`${v}%`} />
)}
{/* Competitor comparison */}
{competitorData.length === 0 ? : (
`${v}%`} />
`${v}%`} />
{competitorData.map((entry, i) => (
|
))}
)}
{/* Cross-model comparison */}
{modelData.length === 0 ? : (
`${v}%`} />
`${v}%`} />
{modelData.map((entry, i) => (
|
))}
)}
{/* Sentiment */}
{sentimentData.length === 0 ? : (
{sentimentData.map((s) => (
正面 {s.total ? Math.round(s.positive / s.total * 100) : 0}%
中性 {s.total ? Math.round(s.neutral / s.total * 100) : 0}%
负面 {s.total ? Math.round(s.negative / s.total * 100) : 0}%
))}
)}
)}
);
}
```
- [ ] **Step 2: Commit**
```bash
cd /d/geo2
git add frontend/src/pages/dashboard
git commit -m "feat: dashboard page with trend, competitor, model, sentiment charts"
```
---
## Task 13: 集成验证 + 启动
**Files:**
- No new files — verify everything runs together
- [ ] **Step 1: 启动 Docker Compose**
```bash
cd /d/geo2
cp .env.example .env
docker-compose up -d postgres redis
```
Expected: postgres 和 redis 容器启动成功。
- [ ] **Step 2: 启动后端(本地开发)**
```bash
cd /d/geo2/backend
cp ../.env.example .env
npm run start:dev
```
Expected:
```
Backend running on port 3000
```
若有 TypeORM synchronize 错误,说明 PostgreSQL 连接有问题,检查 .env DATABASE_URL。
- [ ] **Step 3: 验证后端 API 正常工作**
```bash
# 创建公司
curl -X POST http://localhost:3000/companies \
-H "Content-Type: application/json" \
-d '{"name":"测试公司","industry":"IT"}'
```
Expected: `{"id":"...","name":"测试公司",...}`
```bash
# 添加关键字(替换 COMPANY_ID)
curl -X POST http://localhost:3000/companies/COMPANY_ID/keywords \
-H "Content-Type: application/json" \
-d '{"text":"工业互联网平台"}'
```
Expected: `{"id":"...","text":"工业互联网平台",...}`
- [ ] **Step 4: 启动前端(本地开发)**
```bash
cd /d/geo2/frontend
npm run dev
```
Expected:
```
➜ Local: http://localhost:5173/
```
在浏览器打开 http://localhost:5173,应看到侧边栏导航和公司管理页面。
- [ ] **Step 5: 运行全量后端测试**
```bash
cd /d/geo2/backend && npx jest --no-coverage
```
Expected: All tests PASS
- [ ] **Step 6: 最终 Commit**
```bash
cd /d/geo2
git add .
git commit -m "feat: complete GEO analysis system - backend + frontend integrated"
```
---
## 附录:常见问题
**Q: Bull 连接 Redis 失败**
检查 `REDIS_URL` 环境变量,确保 Redis 容器已启动:`docker-compose up -d redis`
**Q: TypeORM synchronize 报错**
通常是 PostgreSQL 尚未启动,或 DATABASE_URL 格式错误。检查:`docker-compose ps postgres`
**Q: AI 适配器调用超时**
默认 Node fetch 无超时。如需添加超时,在各 adapter 的 fetch 调用中添加 `signal: AbortSignal.timeout(30000)`
**Q: 前端 CORS 报错**
后端已配置 `app.enableCors()`,前端通过 Vite proxy `/api` 转发,不应出现 CORS。若出现,检查 vite.config.ts 的 proxy 配置。
**Q: Cron 未触发**
确认 `task-scheduler.service.ts` 的 `onModuleInit` 已执行,并检查 cron 表达式格式是否正确(5 字段:分 时 日 月 周)。