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:
88
backend/src/routes/fortune.ts
Normal file
88
backend/src/routes/fortune.ts
Normal 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
18
backend/src/server.ts
Normal 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}`);
|
||||
});
|
||||
|
||||
105
backend/src/services/deepseek.ts
Normal file
105
backend/src/services/deepseek.ts
Normal 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/accumulation,wuxing 只能是 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);
|
||||
}
|
||||
}
|
||||
|
||||
171
backend/src/services/fortune-mapper.ts
Normal file
171
backend/src/services/fortune-mapper.ts
Normal 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user