feat: 集成 DeepSeek AI 服务

- 新增 backend 服务 (Express + TypeScript)
- 前端表单提交改为调用后端 API
- DeepSeek 增强分析结果,本地规则作为 fallback
- Docker 双容器部署:web (Nginx) + backend
- Nginx 反向代理 /api 到后端服务
- 本地开发支持 Vite 代理 + backend:dev 脚本

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jowe
2026-03-28 01:37:52 +08:00
parent 7fe1586dc0
commit 3c0fbd5059
22 changed files with 909 additions and 352 deletions

15
backend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
COPY backend/package*.json ./backend/
RUN npm install --prefix backend
COPY shared ./shared
COPY backend ./backend
WORKDIR /app/backend
EXPOSE 3000
CMD ["npm", "run", "start"]

18
backend/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "tianxuan-company-backend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node --import tsx src/server.ts"
},
"dependencies": {
"express": "^5.1.0"
},
"devDependencies": {
"@types/express": "^5.0.3",
"tsx": "^4.20.6",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,88 @@
import type { Request, Response } from 'express';
import { Router } from 'express';
import type { FortuneAnalyzeResponse, UserInput } from '../../../shared/types.js';
import { createDeepSeekServiceFromEnv } from '../services/deepseek.js';
import { createBaselineResult, normalizeFortuneResult } from '../services/fortune-mapper.js';
const router = Router();
router.post('/analyze', async (req: Request, res: Response) => {
const input = validateUserInput(req.body);
if (!input) {
return res.status(400).json({
success: false,
error: {
code: 'INVALID_INPUT',
message: '请输入完整且有效的出生信息。',
},
});
}
const baseline = createBaselineResult(input);
const model = process.env.DEEPSEEK_MODEL?.trim() || 'deepseek-chat';
try {
const service = createDeepSeekServiceFromEnv();
const raw = await service.enhanceFortune(input, baseline);
const data = normalizeFortuneResult(input, raw, baseline);
const payload: FortuneAnalyzeResponse = {
success: true,
data,
meta: {
provider: 'deepseek',
model,
fallbackUsed: false,
},
};
return res.json(payload);
} catch {
const payload: FortuneAnalyzeResponse = {
success: true,
data: baseline,
meta: {
provider: 'deepseek',
model,
fallbackUsed: true,
},
};
return res.json(payload);
}
});
export { router as fortuneRouter };
function validateUserInput(value: unknown): UserInput | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const input = value as Record<string, unknown>;
const birthDate = typeof input.birthDate === 'string' ? input.birthDate.trim() : '';
const birthTime = typeof input.birthTime === 'string' ? input.birthTime.trim() : '';
const gender = input.gender === 'male' || input.gender === 'female' ? input.gender : null;
const birthPlace = typeof input.birthPlace === 'string' ? input.birthPlace.trim() : '';
if (!/^\d{4}-\d{2}-\d{2}$/.test(birthDate)) {
return null;
}
if (!/^\d{2}:\d{2}$/.test(birthTime)) {
return null;
}
if (!gender || !birthPlace || birthPlace.length > 40) {
return null;
}
return {
birthDate,
birthTime,
gender,
birthPlace,
};
}

18
backend/src/server.ts Normal file
View File

@@ -0,0 +1,18 @@
import express from 'express';
import { fortuneRouter } from './routes/fortune.js';
const app = express();
const port = Number.parseInt(process.env.PORT || '3000', 10);
app.use(express.json({ limit: '1mb' }));
app.get('/api/health', (_req, res) => {
res.json({ ok: true });
});
app.use('/api/fortune', fortuneRouter);
app.listen(port, () => {
console.log(`tianxuan-company backend listening on ${port}`);
});

View File

@@ -0,0 +1,105 @@
import type { FortuneResult, UserInput } from '../../../shared/types.js';
interface DeepSeekConfig {
apiKey: string;
baseUrl: string;
model: string;
timeoutMs: number;
}
interface DeepSeekMessage {
role: 'system' | 'user';
content: string;
}
interface DeepSeekResponse {
choices?: Array<{
message?: {
content?: string;
};
}>;
}
export class DeepSeekService {
constructor(private readonly config: DeepSeekConfig) {}
async enhanceFortune(input: UserInput, baseline: FortuneResult): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
try {
const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify({
model: this.config.model,
temperature: 0.7,
response_format: { type: 'json_object' },
messages: buildMessages(input, baseline),
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`DeepSeek 请求失败: ${response.status}`);
}
const data = (await response.json()) as DeepSeekResponse;
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error('DeepSeek 未返回有效内容');
}
return safeJsonParse(content);
} finally {
clearTimeout(timer);
}
}
}
export function createDeepSeekServiceFromEnv(): DeepSeekService {
const apiKey = process.env.DEEPSEEK_API_KEY?.trim();
if (!apiKey) {
throw new Error('缺少 DEEPSEEK_API_KEY 环境变量');
}
return new DeepSeekService({
apiKey,
baseUrl: process.env.DEEPSEEK_BASE_URL?.trim() || 'https://api.deepseek.com',
model: process.env.DEEPSEEK_MODEL?.trim() || 'deepseek-chat',
timeoutMs: Number.parseInt(process.env.REQUEST_TIMEOUT_MS || '20000', 10),
});
}
function buildMessages(input: UserInput, baseline: FortuneResult): DeepSeekMessage[] {
return [
{
role: 'system',
content:
'你是职业命理分析助手。你必须只返回 JSON不要输出任何解释、Markdown 或代码块。请严格返回字段profile、companyRecommendations、jobRecommendations、stageAdvice。保留现有结构数组字段必须是数组matchScore 必须是数字developmentRhythm 只能是 stable/growth/breakthrough/accumulationwuxing 只能是 wood/fire/earth/metal/water。内容风格要求专业、克制、带东方命理气质但不要夸张神化。',
},
{
role: 'user',
content: JSON.stringify({
task: '请在 baseline 基础上增强文案质量和推荐理由,输出适合前端直接渲染的 JSON。若你认为 baseline 某些字段已经足够合理,也可以保留。',
input,
baseline,
}),
},
];
}
function safeJsonParse(content: string): unknown {
try {
return JSON.parse(content);
} catch {
const cleaned = content.replace(/^```json\s*/i, '').replace(/```$/i, '').trim();
return JSON.parse(cleaned);
}
}

View File

@@ -0,0 +1,171 @@
import { analyzeFortune } from '../../../shared/fortune.js';
import type {
CompanyRecommendation,
FortuneProfile,
FortuneResult,
JobRecommendation,
StageAdvice,
UserInput,
} from '../../../shared/types.js';
type PartialArrayValue = unknown[] | string | undefined;
export function createBaselineResult(input: UserInput): FortuneResult {
return analyzeFortune(input);
}
export function normalizeFortuneResult(input: UserInput, raw: unknown, baseline: FortuneResult): FortuneResult {
if (!raw || typeof raw !== 'object') {
return baseline;
}
const source = raw as Record<string, unknown>;
return {
profile: normalizeProfile(source.profile, baseline.profile),
companyRecommendations: normalizeCompanyRecommendations(
source.companyRecommendations,
baseline.companyRecommendations,
),
jobRecommendations: normalizeJobRecommendations(source.jobRecommendations, baseline.jobRecommendations),
stageAdvice: normalizeStageAdvice(source.stageAdvice, baseline.stageAdvice, input),
};
}
function normalizeProfile(value: unknown, baseline: FortuneProfile): FortuneProfile {
const source = asRecord(value);
return {
wuxing: pickEnum(source?.wuxing, ['wood', 'fire', 'earth', 'metal', 'water'], baseline.wuxing),
wuxingLabel: pickString(source?.wuxingLabel, baseline.wuxingLabel),
personality: pickStringArray(source?.personality, baseline.personality, 3),
strengths: pickStringArray(source?.strengths, baseline.strengths, 3),
weaknesses: pickStringArray(source?.weaknesses, baseline.weaknesses, 2),
developmentRhythm: pickEnum(
source?.developmentRhythm,
['stable', 'growth', 'breakthrough', 'accumulation'],
baseline.developmentRhythm,
),
};
}
function normalizeCompanyRecommendations(value: unknown, baseline: CompanyRecommendation[]): CompanyRecommendation[] {
const items = Array.isArray(value) ? value : [];
const normalized = items
.slice(0, 4)
.map((item, index) => {
const source = asRecord(item);
const fallback = baseline[index] || baseline[0];
return {
type: pickString(source?.type, fallback.type),
matchScore: pickScore(source?.matchScore, fallback.matchScore),
reasons: pickStringArray(source?.reasons, fallback.reasons, 2),
suitableFor: pickStringArray(source?.suitableFor, fallback.suitableFor, 1),
notSuitableFor: pickStringArray(source?.notSuitableFor, fallback.notSuitableFor, 1),
stageAdvice: pickString(source?.stageAdvice, fallback.stageAdvice),
};
})
.filter((item) => item.type);
return fillRecommendations(normalized, baseline, 4);
}
function normalizeJobRecommendations(value: unknown, baseline: JobRecommendation[]): JobRecommendation[] {
const items = Array.isArray(value) ? value : [];
const normalized = items
.slice(0, 4)
.map((item, index) => {
const source = asRecord(item);
const fallback = baseline[index] || baseline[0];
return {
category: pickString(source?.category, fallback.category),
matchScore: pickScore(source?.matchScore, fallback.matchScore),
reasons: pickStringArray(source?.reasons, fallback.reasons, 2),
suitableWorkStyle: pickStringArray(source?.suitableWorkStyle, fallback.suitableWorkStyle, 1),
notSuitableFor: pickStringArray(source?.notSuitableFor, fallback.notSuitableFor, 1),
};
})
.filter((item) => item.category);
return fillRecommendations(normalized, baseline, 4);
}
function normalizeStageAdvice(value: unknown, baseline: StageAdvice, input: UserInput): StageAdvice {
const source = asRecord(value);
const locationSuffix = input.birthPlace ? `结合你在${input.birthPlace}出生的地域气场参考,` : '';
return {
overall: pickString(source?.overall, `${locationSuffix}${baseline.overall}`),
careerDirection: pickString(source?.careerDirection, baseline.careerDirection),
jobChangeTiming: pickString(source?.jobChangeTiming, baseline.jobChangeTiming),
platformPreference: pickString(source?.platformPreference, baseline.platformPreference),
keyObservations: pickStringArray(source?.keyObservations, baseline.keyObservations, 3),
};
}
function fillRecommendations<T>(items: T[], baseline: T[], size: number): T[] {
const result = [...items];
for (const fallback of baseline) {
if (result.length >= size) {
break;
}
result.push(fallback);
}
return result.slice(0, size);
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function pickString(value: unknown, fallback: string): string {
if (typeof value !== 'string') {
return fallback;
}
const normalized = value.trim();
return normalized || fallback;
}
function pickStringArray(value: PartialArrayValue, fallback: string[], minLength: number): string[] {
const items = Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean)
: typeof value === 'string'
? value.split(/[,、\n]/).map((item) => item.trim()).filter(Boolean)
: [];
if (items.length >= minLength) {
return items;
}
return fallback;
}
function pickScore(value: unknown, fallback: number): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.max(50, Math.min(99, Math.round(value)));
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) {
return Math.max(50, Math.min(99, parsed));
}
}
return fallback;
}
function pickEnum<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
return typeof value === 'string' && allowed.includes(value as T) ? (value as T) : fallback;
}

14
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "../shared/**/*.ts"]
}