Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"devDependencies": {
"esbuild": "^0.25.5"
}
}
4 changes: 3 additions & 1 deletion server/.env
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
XIAOJU_SURVEY_MONGO_DB_NAME=xiaojuSurvey
XIAOJU_SURVEY_MONGO_URL=
XIAOJU_SURVEY_MONGO_URL=mongodb://localhost:27017/xiaojuSurvey
XIAOJU_SURVEY_MONGO_AUTH_SOURCE=

XIAOJU_SURVEY_REDIS_HOST=
Expand All @@ -16,3 +16,5 @@ XIAOJU_SURVEY_JWT_SECRET=xiaojuSurveyJwtSecret
XIAOJU_SURVEY_JWT_EXPIRES_IN=8h

XIAOJU_SURVEY_LOGGER_FILENAME=./logs/app.log

OPENROUTER_API_KEY=
2 changes: 1 addition & 1 deletion server/.env.production
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
XIAOJU_SURVEY_MONGO_DB_NAME=xiaojuSurvey
XIAOJU_SURVEY_MONGO_URL=
XIAOJU_SURVEY_MONGO_URL=mongodb://localhost:27017/xiaojuSurvey
XIAOJU_SURVEY_MONGO_AUTH_SOURCE=

XIAOJU_SURVEY_REDIS_HOST=
Expand Down
2 changes: 1 addition & 1 deletion server/nest-cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": false
}
}
23 changes: 15 additions & 8 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "XIAOJUSURVEY的server端",
"author": "",
"scripts": {
"build": "nest build",
"build": "nest build && npm run build:server",
"format": "prettier --write \"src/**/*.ts\" \"src/**/__test/*.ts\"",
"local": "ts-node ./scripts/run-local.ts",
"start": "nest start",
Expand All @@ -16,9 +16,14 @@
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand"
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:ai": "jest src/modules/ai",
"build:ts": "tsc -p tsconfig.json",
"build:widgets": "esbuild src/utils/widgets/**/*.jsx src/utils/widgets/**/*.js --outdir=dist/utils/widgets --platform=node --loader:.jsx=jsx --sourcemap",
"build:server": "npm run build:ts && npm run build:widgets"
},
"dependencies": {
"@nestjs/axios": "^4.0.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
Expand All @@ -28,6 +33,7 @@
"@nestjs/swagger": "^7.3.0",
"@nestjs/typeorm": "^10.0.1",
"ali-oss": "^6.20.0",
"axios": "^1.9.0",
"cheerio": "1.0.0-rc.12",
"crypto-js": "^4.2.0",
"dotenv": "^16.3.2",
Expand All @@ -49,16 +55,17 @@
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"svg-captcha": "^1.4.0",
"typeorm": "^0.3.19"
"typeorm": "^0.3.19",
"xss": "^1.0.15"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@nestjs/testing": "^10.4.18",
"@types/ali-oss": "^6.16.11",
"@types/express": "^4.17.17",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.2",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.6",
"@types/lodash": "^4.17.0",
"@types/multer": "^1.4.11",
Expand All @@ -71,12 +78,12 @@
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"jest": "^29.7.0",
"mongodb-memory-server": "^9.1.4",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.0",
"supertest": "^7.1.1",
"ts-jest": "^29.3.4",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
Expand Down
3 changes: 3 additions & 0 deletions server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import { Channel } from './models/channel.entity';
import { ChannelModule } from './modules/channel/channel.module';
import { AppManagerModule } from './modules/appManager/appManager.module';

import { AiModule } from './modules/ai/ai.module';

@Module({
imports: [
ConfigModule.forRoot({
Expand Down Expand Up @@ -120,6 +122,7 @@ import { AppManagerModule } from './modules/appManager/appManager.module';
UpgradeModule,
ChannelModule,
AppManagerModule,
AiModule
],
controllers: [],
providers: [
Expand Down
1 change: 1 addition & 0 deletions server/src/exceptions/httpException.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export class HttpException extends Error {
constructor(
public readonly message: string,
public readonly code: EXCEPTION_CODE,
public readonly data?: any,
) {
super(message);
}
Expand Down
3 changes: 3 additions & 0 deletions server/src/exceptions/httpExceptions.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,20 @@ export class HttpExceptionsFilter implements ExceptionFilter {
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal Server Error';
let code = 500;
let data;

if (exception instanceof HttpException) {
status = HttpStatus.OK; // 非系统报错状态码为200
message = exception.message;
code = exception.code;
data = exception.data;
}

response.status(status).json({
message,
code,
errmsg: exception.message,
data,
});
}
}
2 changes: 2 additions & 0 deletions server/src/interfaces/survey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export interface DataItem {
starStyle?: string;
innerType?: string;
cascaderData: CascaderDate;
quotaDisplay?: boolean;
}

export interface Option {
Expand All @@ -84,6 +85,7 @@ export interface Option {
othersKey?: string;
placeholderDesc: string;
hash: string;
quota?: number;
}

export interface DataConf {
Expand Down
8 changes: 8 additions & 0 deletions server/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import * as dotenv from 'dotenv';
import { resolve } from 'path';

// 这一行,改成:
dotenv.config({
path: resolve(process.cwd(), '.env'),
});

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
Expand Down
48 changes: 48 additions & 0 deletions server/src/modules/ai/__test/ai.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// server/src/modules/ai/__test__/ai.controller.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { AiController } from '../controllers/ai.controller';
import { AiService } from '../services/ai.service';

describe('AiController', () => {
let controller: AiController;
let service: AiService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AiController],
providers: [
{
provide: AiService,
useValue: {
generateQuestions: jest.fn(),
},
},
],
}).compile();

controller = module.get<AiController>(AiController);
service = module.get<AiService>(AiService);
});

it('POST /ai/generate 成功时返回 { success: true, data }', async () => {
const mockData = [{ foo: 'bar' }];
(service.generateQuestions as jest.Mock).mockResolvedValueOnce(mockData);

const response = await controller.generateQuestionnaire({ demand: '测试' });
expect(response).toEqual({ success: true, data: mockData });
});

it('POST /ai/generate 参数校验不通过时抛 400', async () => {
await expect(
controller.generateQuestionnaire({ demand: '' })
).rejects.toMatchObject({ status: 400 });
});

it('Service 抛错时返回 500', async () => {
(service.generateQuestions as jest.Mock).mockRejectedValueOnce(new Error('fail'));
await expect(
controller.generateQuestionnaire({ demand: '有效提示' })
).rejects.toMatchObject({ status: 500, response: expect.stringContaining('fail') });
});
});
62 changes: 62 additions & 0 deletions server/src/modules/ai/__test/ai.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// server/src/modules/ai/__test__/ai.service.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { AiService } from '../services/ai.service';
import { HttpService } from '@nestjs/axios';
import { of, throwError } from 'rxjs';
import { AxiosResponse, InternalAxiosRequestConfig } from 'axios';

// 1. 第一件事:Mock 掉 textToSchema 模块
jest.mock('../../../utils/textToSchema', () => ({
textToSchema: (s: string) => JSON.parse(s),
}));

// 2. 放宽测试超时时间,以防重试延迟
jest.setTimeout(20000);

describe('AiService', () => {
let service: AiService;
let httpService: HttpService;

beforeEach(async () => {
// 3. 完全替换 HttpService,保证网络层可控
const fakeHttp = { post: jest.fn() };

const module: TestingModule = await Test.createTestingModule({
providers: [
AiService,
{ provide: HttpService, useValue: fakeHttp },
],
}).compile();

service = module.get<AiService>(AiService);
httpService = module.get<HttpService>(HttpService);
});

it('should parse and return schema array when AI 接口返回正常', async () => {
// 4. 准备一个标准的 AxiosResponse
const fakeResponse: AxiosResponse = {
data: { choices: [{ message: { content: '[{"q":"问1"},{"q":"问2"}]' } }] },
status: 200,
statusText: 'OK',
headers: {},
config: { headers: {} } as InternalAxiosRequestConfig,
};

// 5. 每次 post 都返回同样的 Observable,避免 retry 拿到 undefined
(httpService.post as jest.Mock).mockReturnValue(of(fakeResponse));

// 6. 调用并断言
const result = await service.generateQuestions('测试提示');
expect(result).toEqual([{ q: '问1' }, { q: '问2' }]);
});

it('should throw if AI API returns error', async () => {
// 网络错误,每次 post 都 throw
(httpService.post as jest.Mock).mockReturnValue(
throwError(() => new Error('network'))
);

await expect(service.generateQuestions('提示')).rejects.toThrow('network');
});
});
19 changes: 19 additions & 0 deletions server/src/modules/ai/ai.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// server/src/modules/ai/ai.module.ts

import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { AiService } from './services/ai.service';
import { AiController } from './controllers/ai.controller';

@Module({
imports: [
HttpModule.register({
timeout: 30000,
maxRedirects: 5,
}),
],
providers: [AiService],
controllers: [AiController],
exports: [AiService],
})
export class AiModule {}
40 changes: 40 additions & 0 deletions server/src/modules/ai/controllers/ai.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// server/src/modules/ai/ai.controller.ts

import { Body, Controller, HttpException, HttpStatus, Post } from '@nestjs/common';
import { AiService } from '../services/ai.service';

// 定义请求体 DTO
class GenerateDto {
demand: string; // 用户传来的问卷需求描述
}

@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}

/**
* POST /ai/generate
* 接收 { demand: string },调用 AiService 生成问卷结构并返回
*/
@Post('generate')
async generateQuestionnaire(@Body() generateDto: GenerateDto) {
const { demand } = generateDto;

if (!demand || typeof demand !== 'string' || !demand.trim()) {
throw new HttpException('demand 参数不能为空', HttpStatus.BAD_REQUEST);
}

try {
// 调用 Service 层生成
const questions = await this.aiService.generateQuestions(demand.trim());
// questions 应该是 Array<Record<string, any>> 格式
return { success: true, data: questions };
} catch (err) {
// 捕获 Service 层抛出的任何异常,返回 500
throw new HttpException(
'AI 生成问卷失败: ' + (err?.message || '未知错误'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
Loading
Loading