feat: AI 驱动的配方研发智能平台 v0.1
核心功能: - M3 配方记录: 创建/编辑/详情/可视化编辑/AI提取/版本历史/版本对比 - M1 颜色引擎: D3.js 色相环/滑条微调/ΔE计算/取色棒/AI配色推荐 - M2 可视化编辑器: ECharts饼图/成分滑条/AI预测/雷达图/仪表盘 - M4 配方推演: 约束设置/SSE推演/方案对比/散点图 - 平台: NL智能搜索/项目管理/CSV导出/JWT认证/全局搜索 技术栈: - 前端: React + Vite + Tailwind CSS 4 + Zustand + TanStack Query - 后端: Fastify 5 + Prisma 7 + PostgreSQL + pgvector - AI: OpenAI/DeepSeek API 调用 + Prompt模板 + 缓存/降级/限流 - 测试: Vitest 42 tests (26 API集成 + 16 色彩模块)
This commit is contained in:
48
backend/src/app.ts
Normal file
48
backend/src/app.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import Fastify from 'fastify'
|
||||
import type { FastifyError } from 'fastify'
|
||||
import cors from '@fastify/cors'
|
||||
import { healthRoutes } from './routes/health.js'
|
||||
import { ingredientRoutes } from './routes/ingredients.js'
|
||||
import { formulaRoutes } from './routes/formulas.js'
|
||||
import { aiRoutes } from './routes/ai.js'
|
||||
import { colorRoutes } from './routes/color.js'
|
||||
import { projectRoutes } from './routes/projects.js'
|
||||
import { authRoutes } from './routes/auth.js'
|
||||
import { configRoutes } from './routes/config.js'
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: { colorize: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await app.register(cors, {
|
||||
origin: ['http://localhost:5173'],
|
||||
credentials: true,
|
||||
})
|
||||
|
||||
app.setErrorHandler((error: FastifyError | Error, _request, reply) => {
|
||||
app.log.error(error)
|
||||
const code = 'statusCode' in error ? error.statusCode : undefined
|
||||
const statusCode = code ?? 500
|
||||
reply.status(statusCode).send({
|
||||
error: statusCode >= 500 ? 'Internal Server Error' : error.message,
|
||||
statusCode,
|
||||
})
|
||||
})
|
||||
|
||||
await app.register(healthRoutes, { prefix: '/api' })
|
||||
await app.register(ingredientRoutes, { prefix: '/api/ingredients' })
|
||||
await app.register(formulaRoutes, { prefix: '/api/formulas' })
|
||||
await app.register(aiRoutes, { prefix: '/api/ai' })
|
||||
await app.register(colorRoutes, { prefix: '/api/color' })
|
||||
await app.register(projectRoutes, { prefix: '/api/projects' })
|
||||
await app.register(authRoutes, { prefix: '/api/auth' })
|
||||
await app.register(configRoutes, { prefix: '/api/config' })
|
||||
|
||||
return app
|
||||
}
|
||||
8
backend/src/lib/prisma.ts
Normal file
8
backend/src/lib/prisma.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { PrismaClient } from '../generated/prisma/client.js'
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
|
||||
const connectionString = process.env['DATABASE_URL'] ?? 'postgresql://colorfull:colorfull@localhost:5432/colorfull'
|
||||
|
||||
export const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString }),
|
||||
})
|
||||
113
backend/src/routes/ai.ts
Normal file
113
backend/src/routes/ai.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { aiService } from '../services/ai/index.js'
|
||||
|
||||
async function predictFormula(request: FastifyRequest<{ Body: { ingredients: Array<{ name: string; percentage: number; category: string }> } }>, reply: FastifyReply) {
|
||||
const { ingredients } = request.body
|
||||
if (!ingredients || ingredients.length === 0) {
|
||||
return reply.status(400).send({ error: '成分列表不能为空' })
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await aiService.predictMetrics(ingredients)
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'result', content: result })}\n\n`)
|
||||
} catch {
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'error', content: '预测失败' })}\n\n`)
|
||||
}
|
||||
reply.raw.end()
|
||||
}
|
||||
|
||||
async function exploreFormula(request: FastifyRequest<{ Body: {
|
||||
baseFormula?: { name: string; ingredients: Array<{ name: string; percentage: number }> }
|
||||
costLimit?: number; keepIngredients?: string[]; excludeIngredients?: string[]; targetMetrics?: Record<string, number>
|
||||
} }>, reply: FastifyReply) {
|
||||
const constraints = request.body
|
||||
if (!constraints.costLimit && !constraints.targetMetrics && !constraints.excludeIngredients) {
|
||||
return reply.status(400).send({ error: '至少设置一个约束条件' })
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await aiService.generateFormula(constraints)
|
||||
const parsed = JSON.parse(result) as Array<Record<string, unknown>>
|
||||
for (const option of parsed) {
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'option', option })}\n\n`)
|
||||
}
|
||||
} catch {
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'error', content: '推演失败' })}\n\n`)
|
||||
}
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
|
||||
reply.raw.end()
|
||||
}
|
||||
|
||||
async function extractFormula(request: FastifyRequest<{ Body: { text: string } }>, reply: FastifyReply) {
|
||||
const { text } = request.body
|
||||
if (!text || text.trim().length === 0) {
|
||||
return reply.status(400).send({ error: '配方文本不能为空' })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await aiService.extractFormula(text)
|
||||
const parsed = JSON.parse(result) as { ingredients?: Array<Record<string, unknown>> }
|
||||
return reply.send({ data: parsed.ingredients ?? [] })
|
||||
} catch (err) {
|
||||
request.log.error(err)
|
||||
return reply.status(500).send({ error: 'AI 提取失败,请重试' })
|
||||
}
|
||||
}
|
||||
|
||||
async function nlSearch(request: FastifyRequest<{ Querystring: { q: string } }>, reply: FastifyReply) {
|
||||
const q = request.query.q?.trim()
|
||||
if (!q) return reply.status(400).send({ error: '搜索词不能为空' })
|
||||
|
||||
try {
|
||||
const aiResult = await aiService.parseNLQuery(q)
|
||||
const parsed = JSON.parse(aiResult) as { keywords?: string[]; filters?: Record<string, unknown> }
|
||||
const keywords = parsed.keywords?.[0] ?? q
|
||||
|
||||
const formulas = await prisma.formula.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { contains: keywords, mode: 'insensitive' } },
|
||||
{ description: { contains: keywords, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
take: 20,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: { project: { select: { name: true } } },
|
||||
})
|
||||
|
||||
return reply.send({ data: formulas, keywords })
|
||||
} catch {
|
||||
const formulas = await prisma.formula.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { contains: q, mode: 'insensitive' } },
|
||||
{ description: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
take: 20,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: { project: { select: { name: true } } },
|
||||
})
|
||||
return reply.send({ data: formulas, keywords: [q] })
|
||||
}
|
||||
}
|
||||
|
||||
export async function aiRoutes(app: FastifyInstance) {
|
||||
app.post('/extract-formula', extractFormula)
|
||||
app.post('/predict-formula', predictFormula)
|
||||
app.post('/explore-formula', exploreFormula)
|
||||
app.get('/search', nlSearch)
|
||||
}
|
||||
76
backend/src/routes/auth.ts
Normal file
76
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { randomBytes, timingSafeEqual, scryptSync, createHash } from 'crypto'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
const JWT_SECRET = process.env['JWT_SECRET'] ?? 'dev-secret-change-me'
|
||||
|
||||
function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString('hex')
|
||||
const hash = scryptSync(password, salt, 64).toString('hex')
|
||||
return `${salt}:${hash}`
|
||||
}
|
||||
|
||||
function verifyPassword(password: string, stored: string): boolean {
|
||||
const [salt, hash] = stored.split(':')
|
||||
if (!salt || !hash) return false
|
||||
const computed = scryptSync(password, salt, 64).toString('hex')
|
||||
return timingSafeEqual(Buffer.from(hash), Buffer.from(computed))
|
||||
}
|
||||
|
||||
function signToken(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url')
|
||||
const body = Buffer.from(JSON.stringify({ ...payload, exp: Math.floor(Date.now() / 1000) + 86400 })).toString('base64url')
|
||||
const sig = createHash('sha256').update(`${header}.${body}.${JWT_SECRET}`).digest('base64url')
|
||||
return `${header}.${body}.${sig}`
|
||||
}
|
||||
|
||||
function verifyToken(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const expected = createHash('sha256').update(`${parts[0]}.${parts[1]}.${JWT_SECRET}`).digest('base64url')
|
||||
if (!timingSafeEqual(Buffer.from(parts[2]!), Buffer.from(expected))) return null
|
||||
const payload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString()) as Record<string, unknown>
|
||||
if (typeof payload.exp === 'number' && payload.exp < Date.now() / 1000) return null
|
||||
return payload
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
async function register(request: FastifyRequest<{ Body: { username: string; password: string } }>, reply: FastifyReply) {
|
||||
const { username, password } = request.body
|
||||
if (!username || !password) return reply.status(400).send({ error: '用户名和密码为必填项' })
|
||||
if (password.length < 4) return reply.status(400).send({ error: '密码至少4位' })
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { username } })
|
||||
if (existing) return reply.status(409).send({ error: '用户名已存在' })
|
||||
|
||||
const user = await prisma.user.create({ data: { username, passwordHash: hashPassword(password) } })
|
||||
const token = signToken({ userId: user.id })
|
||||
return reply.status(201).send({ data: { id: user.id, username: user.username, role: user.role, token } })
|
||||
}
|
||||
|
||||
async function login(request: FastifyRequest<{ Body: { username: string; password: string } }>, reply: FastifyReply) {
|
||||
const { username, password } = request.body
|
||||
const user = await prisma.user.findUnique({ where: { username } })
|
||||
if (!user || !verifyPassword(password, user.passwordHash)) {
|
||||
return reply.status(401).send({ error: '用户名或密码错误' })
|
||||
}
|
||||
const token = signToken({ userId: user.id })
|
||||
return reply.send({ data: { id: user.id, username: user.username, role: user.role, token } })
|
||||
}
|
||||
|
||||
async function me(request: FastifyRequest, reply: FastifyReply) {
|
||||
const auth = request.headers.authorization
|
||||
if (!auth?.startsWith('Bearer ')) return reply.status(401).send({ error: '未认证' })
|
||||
const payload = verifyToken(auth.slice(7))
|
||||
if (!payload) return reply.status(401).send({ error: 'Token 无效' })
|
||||
const user = await prisma.user.findUnique({ where: { id: payload.userId as string } })
|
||||
if (!user) return reply.status(401).send({ error: '用户不存在' })
|
||||
return reply.send({ data: { id: user.id, username: user.username, role: user.role } })
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.post('/register', register)
|
||||
app.post('/login', login)
|
||||
app.get('/me', me)
|
||||
}
|
||||
91
backend/src/routes/color.ts
Normal file
91
backend/src/routes/color.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import type { Prisma } from '../generated/prisma/client.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { aiService } from '../services/ai/index.js'
|
||||
|
||||
interface LabInput {
|
||||
L: number; a: number; b: number
|
||||
}
|
||||
|
||||
function euclideanLabDistance(lab1: LabInput, lab2: LabInput): number {
|
||||
return Math.sqrt((lab1.L - lab2.L) ** 2 + (lab1.a - lab2.a) ** 2 + (lab1.b - lab2.b) ** 2)
|
||||
}
|
||||
|
||||
async function recommend(request: FastifyRequest<{ Body: { targetLab: LabInput } }>, reply: FastifyReply) {
|
||||
const { targetLab } = request.body
|
||||
if (!targetLab || targetLab.L === undefined) {
|
||||
return reply.status(400).send({ error: 'targetLab 为必填项 (L, a, b)' })
|
||||
}
|
||||
|
||||
const allColorFormulas = await prisma.colorFormula.findMany({
|
||||
select: { id: true, name: true, targetLab: true, actualLab: true, deltaE: true },
|
||||
})
|
||||
|
||||
const matched = allColorFormulas
|
||||
.map(f => {
|
||||
const tl = f.targetLab as unknown as LabInput
|
||||
return { ...f, distance: euclideanLabDistance(targetLab, tl) }
|
||||
})
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.slice(0, 5)
|
||||
|
||||
const topName = matched[0] ? `${matched[0].name} (ΔE≈${matched[0].deltaE?.toFixed(2) ?? matched[0].distance.toFixed(2)})` : '无'
|
||||
|
||||
const aiResult = await aiService.recommendColorants(targetLab)
|
||||
let recommendations: Array<Record<string, unknown>> = []
|
||||
try {
|
||||
const parsed = JSON.parse(aiResult) as { recommendations?: Array<Record<string, unknown>> }
|
||||
recommendations = parsed.recommendations ?? []
|
||||
} catch { }
|
||||
|
||||
return reply.send({
|
||||
recommendations,
|
||||
matchedFormulas: matched.map(m => ({ id: m.id, name: m.name, deltaE: m.deltaE ?? m.distance })),
|
||||
})
|
||||
}
|
||||
|
||||
async function matchFormulas(request: FastifyRequest<{ Querystring: { L: string; a: string; b: string; limit?: string } }>, reply: FastifyReply) {
|
||||
const L = parseFloat(request.query.L)
|
||||
const a = parseFloat(request.query.a)
|
||||
const b = parseFloat(request.query.b)
|
||||
const limit = Math.min(20, parseInt(request.query.limit ?? '5', 10) || 5)
|
||||
|
||||
if (isNaN(L) || isNaN(a) || isNaN(b)) {
|
||||
return reply.status(400).send({ error: 'L, a, b 参数为必填数字' })
|
||||
}
|
||||
|
||||
const allColorFormulas = await prisma.colorFormula.findMany({
|
||||
select: { id: true, name: true, targetLab: true, deltaE: true, colorantComposition: true },
|
||||
})
|
||||
|
||||
const target: LabInput = { L, a, b }
|
||||
const matched = allColorFormulas
|
||||
.map(f => ({ ...f, distance: euclideanLabDistance(target, f.targetLab as unknown as LabInput) }))
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.slice(0, limit)
|
||||
|
||||
return reply.send({ data: matched })
|
||||
}
|
||||
|
||||
async function saveColorFormula(request: FastifyRequest<{ Body: {
|
||||
name?: string; targetLab: LabInput; actualLab?: LabInput; deltaE?: number; colorantComposition?: unknown; formulaId?: string
|
||||
} }>, reply: FastifyReply) {
|
||||
const formula = await prisma.colorFormula.create({
|
||||
data: {
|
||||
name: request.body.name ?? '未命名颜色配方',
|
||||
targetLab: request.body.targetLab as unknown as Prisma.InputJsonValue,
|
||||
actualLab: request.body.actualLab as unknown as Prisma.InputJsonValue ?? null,
|
||||
deltaE: request.body.deltaE ?? null,
|
||||
colorantComposition: request.body.colorantComposition as unknown as Prisma.InputJsonValue ?? null,
|
||||
formulaId: request.body.formulaId ?? null,
|
||||
createdBy: 'system',
|
||||
},
|
||||
})
|
||||
return reply.status(201).send({ data: formula })
|
||||
}
|
||||
|
||||
export async function colorRoutes(app: FastifyInstance) {
|
||||
app.post('/recommend', recommend)
|
||||
app.get('/formulas/match', matchFormulas)
|
||||
app.post('/formulas', saveColorFormula)
|
||||
}
|
||||
47
backend/src/routes/config.ts
Normal file
47
backend/src/routes/config.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { aiService } from '../services/ai/index.js'
|
||||
|
||||
interface ConfigBody {
|
||||
openaiKey?: string
|
||||
deepseekKey?: string
|
||||
openaiBaseUrl?: string
|
||||
deepseekBaseUrl?: string
|
||||
aiMock?: string
|
||||
}
|
||||
|
||||
async function getConfig(_request: FastifyRequest, reply: FastifyReply) {
|
||||
return reply.send({
|
||||
aiMock: process.env['AI_MOCK'] ?? 'true',
|
||||
hasOpenAI: !!process.env['OPENAI_API_KEY'],
|
||||
hasDeepseek: !!process.env['DEEPSEEK_API_KEY'],
|
||||
})
|
||||
}
|
||||
|
||||
async function updateConfig(request: FastifyRequest<{ Body: ConfigBody }>, reply: FastifyReply) {
|
||||
const { openaiKey, deepseekKey, openaiBaseUrl, deepseekBaseUrl, aiMock } = request.body
|
||||
|
||||
if (aiMock !== undefined) process.env['AI_MOCK'] = aiMock
|
||||
if (openaiKey) process.env['OPENAI_API_KEY'] = openaiKey
|
||||
if (deepseekKey) process.env['DEEPSEEK_API_KEY'] = deepseekKey
|
||||
if (openaiBaseUrl !== undefined) process.env['OPENAI_BASE_URL'] = openaiBaseUrl
|
||||
if (deepseekBaseUrl !== undefined) process.env['DEEPSEEK_BASE_URL'] = deepseekBaseUrl
|
||||
|
||||
aiService.reload()
|
||||
return reply.send({ ok: true })
|
||||
}
|
||||
|
||||
async function testApi(request: FastifyRequest<{ Body: { provider: string } }>, reply: FastifyReply) {
|
||||
const { provider } = request.body
|
||||
try {
|
||||
const result = await aiService.testConnection(provider)
|
||||
return reply.send({ ok: true, model: result })
|
||||
} catch (err) {
|
||||
return reply.status(500).send({ ok: false, error: (err as Error).message })
|
||||
}
|
||||
}
|
||||
|
||||
export async function configRoutes(app: FastifyInstance) {
|
||||
app.get('/', getConfig)
|
||||
app.put('/', updateConfig)
|
||||
app.post('/test', testApi)
|
||||
}
|
||||
218
backend/src/routes/formulas.test.ts
Normal file
218
backend/src/routes/formulas.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { buildApp } from '../app.js'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
let app: FastifyInstance
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp()
|
||||
await app.ready()
|
||||
|
||||
await app.inject({
|
||||
method: 'POST', url: '/api/ingredients',
|
||||
payload: { inciName: '__system__', chineseName: '__system__', functionCategory: 'other' },
|
||||
})
|
||||
|
||||
const { prisma } = await import('../lib/prisma.js')
|
||||
await prisma.user.upsert({
|
||||
where: { username: 'system' },
|
||||
update: {},
|
||||
create: { id: 'system', username: 'system', passwordHash: 'test', role: 'admin' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
async function createTestIngredient(name: string) {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/ingredients',
|
||||
payload: { inciName: name, chineseName: name, functionCategory: 'humectant' },
|
||||
})
|
||||
return res.json().data.id as string
|
||||
}
|
||||
|
||||
describe('POST /api/formulas', () => {
|
||||
it('创建配方成功(含相和成分)', async () => {
|
||||
const ing1Id = await createTestIngredient('FormulaTest1')
|
||||
const ing2Id = await createTestIngredient('FormulaTest2')
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/formulas',
|
||||
payload: {
|
||||
name: '测试精华液',
|
||||
description: '集成测试配方',
|
||||
phases: [
|
||||
{ name: '水相', ingredients: [{ ingredientId: ing1Id, percentage: 60 }] },
|
||||
{ name: '油相', ingredients: [{ ingredientId: ing2Id, percentage: 40 }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = res.json()
|
||||
expect(body.data.name).toBe('测试精华液')
|
||||
expect(body.data.currentVersion).toBe(1)
|
||||
expect(body.data.versions).toBeDefined()
|
||||
expect(body.data.versions.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('缺少配方名称返回 400', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/formulas',
|
||||
payload: { phases: [{ name: '水相', ingredients: [] }] },
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('空配方(无成分)返回 400', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/formulas',
|
||||
payload: { name: '空配方', phases: [{ name: '水相', ingredients: [] }] },
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('比例总和不足 99.5% 返回 400', async () => {
|
||||
const ingId = await createTestIngredient('PercentTest1')
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/formulas',
|
||||
payload: {
|
||||
name: '比例不足',
|
||||
phases: [{ name: '水相', ingredients: [{ ingredientId: ingId, percentage: 50 }] }],
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('比例总和超过 100.5% 返回 400', async () => {
|
||||
const ing1Id = await createTestIngredient('PercentTest2')
|
||||
const ing2Id = await createTestIngredient('PercentTest3')
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/formulas',
|
||||
payload: {
|
||||
name: '比例超标',
|
||||
phases: [
|
||||
{ name: '水相', ingredients: [{ ingredientId: ing1Id, percentage: 80 }, { ingredientId: ing2Id, percentage: 30 }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('单个成分比例 ≤ 0 返回 400', async () => {
|
||||
const ingId = await createTestIngredient('PercentTest4')
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/formulas',
|
||||
payload: {
|
||||
name: '零比例',
|
||||
phases: [
|
||||
{ name: '水相', ingredients: [{ ingredientId: ingId, percentage: 0 }, { ingredientId: ingId, percentage: 100 }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/formulas/:id', () => {
|
||||
it('返回配方详情含成分', async () => {
|
||||
const ingId = await createTestIngredient('DetailTest1')
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/formulas',
|
||||
payload: {
|
||||
name: '详情测试',
|
||||
phases: [{ name: '水相', ingredients: [{ ingredientId: ingId, percentage: 100 }] }],
|
||||
},
|
||||
})
|
||||
const id = createRes.json().data.id
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/formulas/${id}` })
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json()
|
||||
expect(body.data.name).toBe('详情测试')
|
||||
expect(body.data.versions).toBeDefined()
|
||||
})
|
||||
|
||||
it('不存在的配方返回 404', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/formulas/nonexistent' })
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/formulas/:id/composition', () => {
|
||||
it('更新成分后版本号递增', async () => {
|
||||
const ing1Id = await createTestIngredient('VersionTest1')
|
||||
const ing2Id = await createTestIngredient('VersionTest2')
|
||||
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/formulas',
|
||||
payload: {
|
||||
name: '版本测试',
|
||||
phases: [{ name: '水相', ingredients: [{ ingredientId: ing1Id, percentage: 100 }] }],
|
||||
},
|
||||
})
|
||||
const id = createRes.json().data.id
|
||||
expect(createRes.json().data.currentVersion).toBe(1)
|
||||
|
||||
const updateRes = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/formulas/${id}/composition`,
|
||||
payload: {
|
||||
phases: [
|
||||
{ name: '水相', ingredients: [{ ingredientId: ing1Id, percentage: 60 }] },
|
||||
{ name: '油相', ingredients: [{ ingredientId: ing2Id, percentage: 40 }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(updateRes.statusCode).toBe(200)
|
||||
expect(updateRes.json().data.currentVersion).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/formulas', () => {
|
||||
it('返回配方列表和分页', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/formulas' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json()
|
||||
expect(Array.isArray(body.data)).toBe(true)
|
||||
expect(body.pagination).toBeDefined()
|
||||
})
|
||||
|
||||
it('支持搜索', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/formulas?search=测试精华液' })
|
||||
const body = res.json()
|
||||
expect(body.data.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/formulas/:id', () => {
|
||||
it('删除配方成功', async () => {
|
||||
const ingId = await createTestIngredient('DeleteTest1')
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/formulas',
|
||||
payload: {
|
||||
name: '待删除',
|
||||
phases: [{ name: '水相', ingredients: [{ ingredientId: ingId, percentage: 100 }] }],
|
||||
},
|
||||
})
|
||||
const id = createRes.json().data.id
|
||||
|
||||
const res = await app.inject({ method: 'DELETE', url: `/api/formulas/${id}` })
|
||||
expect(res.statusCode).toBe(204)
|
||||
|
||||
const getRes = await app.inject({ method: 'GET', url: `/api/formulas/${id}` })
|
||||
expect(getRes.statusCode).toBe(404)
|
||||
})
|
||||
})
|
||||
295
backend/src/routes/formulas.ts
Normal file
295
backend/src/routes/formulas.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import type { Prisma } from '../generated/prisma/client.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
interface PhaseInput {
|
||||
name: string
|
||||
sortOrder?: number
|
||||
ingredients: { ingredientId: string; percentage: number; processNotes?: string }[]
|
||||
}
|
||||
|
||||
interface CreateFormulaBody {
|
||||
name: string
|
||||
description?: string
|
||||
projectId?: string
|
||||
phases: PhaseInput[]
|
||||
}
|
||||
|
||||
interface UpdateCompositionBody {
|
||||
phases: PhaseInput[]
|
||||
}
|
||||
|
||||
function validatePercentages(phases: PhaseInput[]): string | null {
|
||||
const allIngredients = phases.flatMap(p => p.ingredients)
|
||||
if (allIngredients.length === 0) return '配方至少需要一个成分'
|
||||
|
||||
for (const ing of allIngredients) {
|
||||
if (ing.percentage <= 0 || ing.percentage > 100) {
|
||||
return `成分比例必须在 0-100 之间,当前值: ${ing.percentage}`
|
||||
}
|
||||
}
|
||||
|
||||
const total = allIngredients.reduce((sum, ing) => sum + ing.percentage, 0)
|
||||
if (total < 99.5 || total > 100.5) {
|
||||
return `成分比例总和必须在 99.5%-100.5% 之间,当前总和: ${total.toFixed(2)}%`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function createFormula(request: FastifyRequest<{ Body: CreateFormulaBody }>, reply: FastifyReply) {
|
||||
const { name, description, projectId, phases } = request.body
|
||||
|
||||
if (!name || !phases || !Array.isArray(phases) || phases.length === 0) {
|
||||
return reply.status(400).send({ error: '配方名称和至少一个相为必填项' })
|
||||
}
|
||||
|
||||
const percentError = validatePercentages(phases)
|
||||
if (percentError) return reply.status(400).send({ error: percentError })
|
||||
|
||||
const createdBy = 'system'
|
||||
|
||||
const formula = await prisma.$transaction(async (tx) => {
|
||||
const f = await tx.formula.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
projectId: projectId ?? null,
|
||||
createdBy,
|
||||
currentVersion: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const version = await tx.formulaVersion.create({
|
||||
data: {
|
||||
formulaId: f.id,
|
||||
versionNumber: 1,
|
||||
description: '初始版本',
|
||||
snapshotData: { phases } as unknown as Prisma.InputJsonValue,
|
||||
createdBy,
|
||||
},
|
||||
})
|
||||
|
||||
for (const phaseInput of phases) {
|
||||
const phase = await tx.phase.create({
|
||||
data: {
|
||||
name: phaseInput.name,
|
||||
formulaId: version.id,
|
||||
sortOrder: phaseInput.sortOrder ?? 0,
|
||||
},
|
||||
})
|
||||
|
||||
for (const ing of phaseInput.ingredients) {
|
||||
await tx.formulaIngredient.create({
|
||||
data: {
|
||||
formulaVersionId: version.id,
|
||||
phaseId: phase.id,
|
||||
ingredientId: ing.ingredientId,
|
||||
percentage: ing.percentage,
|
||||
processNotes: ing.processNotes ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return f
|
||||
})
|
||||
|
||||
const result = await prisma.formula.findUnique({
|
||||
where: { id: formula.id },
|
||||
include: {
|
||||
versions: {
|
||||
orderBy: { versionNumber: 'desc' },
|
||||
take: 1,
|
||||
include: { phases: { include: { ingredients: { include: { ingredient: true } } } } },
|
||||
},
|
||||
project: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return reply.status(201).send({ data: result })
|
||||
}
|
||||
|
||||
async function getFormula(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
const formula = await prisma.formula.findUnique({
|
||||
where: { id: request.params.id },
|
||||
include: {
|
||||
versions: {
|
||||
orderBy: { versionNumber: 'desc' },
|
||||
take: 1,
|
||||
include: { phases: { orderBy: { sortOrder: 'asc' }, include: { ingredients: { include: { ingredient: true } } } } },
|
||||
},
|
||||
project: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!formula) return reply.status(404).send({ error: '配方不存在' })
|
||||
return reply.send({ data: formula })
|
||||
}
|
||||
|
||||
async function listFormulas(request: FastifyRequest<{ Querystring: Record<string, string> }>, reply: FastifyReply) {
|
||||
const { projectId, search, page = '1', limit = '20', sortBy = 'updatedAt', sortOrder = 'desc' } = request.query
|
||||
|
||||
const pageNum = Math.max(1, parseInt(page, 10) || 1)
|
||||
const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10) || 20))
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
if (projectId) where.projectId = projectId
|
||||
if (search && search.length >= 1) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.formula.findMany({
|
||||
where,
|
||||
skip: (pageNum - 1) * limitNum,
|
||||
take: limitNum,
|
||||
orderBy: { [sortBy]: sortOrder },
|
||||
include: { project: { select: { id: true, name: true } } },
|
||||
}),
|
||||
prisma.formula.count({ where }),
|
||||
])
|
||||
|
||||
return reply.send({ data, pagination: { page: pageNum, limit: limitNum, total, totalPages: Math.ceil(total / limitNum) } })
|
||||
}
|
||||
|
||||
async function updateFormula(request: FastifyRequest<{ Params: { id: string }; Body: { name?: string; description?: string } }>, reply: FastifyReply) {
|
||||
const existing = await prisma.formula.findUnique({ where: { id: request.params.id } })
|
||||
if (!existing) return reply.status(404).send({ error: '配方不存在' })
|
||||
|
||||
const formula = await prisma.formula.update({
|
||||
where: { id: request.params.id },
|
||||
data: { ...request.body },
|
||||
})
|
||||
|
||||
return reply.send({ data: formula })
|
||||
}
|
||||
|
||||
async function updateComposition(request: FastifyRequest<{ Params: { id: string }; Body: UpdateCompositionBody }>, reply: FastifyReply) {
|
||||
const { phases } = request.body
|
||||
|
||||
if (!phases || !Array.isArray(phases) || phases.length === 0) {
|
||||
return reply.status(400).send({ error: '配方至少需要一个相' })
|
||||
}
|
||||
|
||||
const percentError = validatePercentages(phases)
|
||||
if (percentError) return reply.status(400).send({ error: percentError })
|
||||
|
||||
const existing = await prisma.formula.findUnique({ where: { id: request.params.id } })
|
||||
if (!existing) return reply.status(404).send({ error: '配方不存在' })
|
||||
|
||||
const createdBy = 'system'
|
||||
|
||||
const formula = await prisma.$transaction(async (tx) => {
|
||||
const newVersionNumber = existing.currentVersion + 1
|
||||
|
||||
const version = await tx.formulaVersion.create({
|
||||
data: {
|
||||
formulaId: existing.id,
|
||||
versionNumber: newVersionNumber,
|
||||
description: `版本 v${newVersionNumber}`,
|
||||
snapshotData: { phases } as unknown as Prisma.InputJsonValue,
|
||||
createdBy,
|
||||
},
|
||||
})
|
||||
|
||||
for (const phaseInput of phases) {
|
||||
const phase = await tx.phase.create({
|
||||
data: {
|
||||
name: phaseInput.name,
|
||||
formulaId: version.id,
|
||||
sortOrder: phaseInput.sortOrder ?? 0,
|
||||
},
|
||||
})
|
||||
|
||||
for (const ing of phaseInput.ingredients) {
|
||||
await tx.formulaIngredient.create({
|
||||
data: {
|
||||
formulaVersionId: version.id,
|
||||
phaseId: phase.id,
|
||||
ingredientId: ing.ingredientId,
|
||||
percentage: ing.percentage,
|
||||
processNotes: ing.processNotes ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return tx.formula.update({
|
||||
where: { id: existing.id },
|
||||
data: { currentVersion: newVersionNumber },
|
||||
})
|
||||
})
|
||||
|
||||
return reply.send({ data: formula })
|
||||
}
|
||||
|
||||
async function getVersions(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
const existing = await prisma.formula.findUnique({ where: { id: request.params.id } })
|
||||
if (!existing) return reply.status(404).send({ error: '配方不存在' })
|
||||
|
||||
const versions = await prisma.formulaVersion.findMany({
|
||||
where: { formulaId: request.params.id },
|
||||
orderBy: { versionNumber: 'desc' },
|
||||
include: { phases: { orderBy: { sortOrder: 'asc' }, include: { ingredients: { include: { ingredient: true } } } } },
|
||||
})
|
||||
|
||||
return reply.send({ data: versions })
|
||||
}
|
||||
|
||||
async function exportFormulas(_request: FastifyRequest, reply: FastifyReply) {
|
||||
const formulas = await prisma.formula.findMany({
|
||||
include: {
|
||||
versions: { orderBy: { versionNumber: 'desc' }, take: 1,
|
||||
include: { phases: { include: { ingredients: { include: { ingredient: true } } } } } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
const rows: string[][] = [['配方名', '版本', '相', '成分INCI', '成分中文', '比例%', '更新时间']]
|
||||
for (const f of formulas) {
|
||||
const v = f.versions[0]
|
||||
if (!v) continue
|
||||
for (const p of v.phases) {
|
||||
for (const i of p.ingredients) {
|
||||
rows.push([f.name, `v${f.currentVersion}`, p.name, i.ingredient.inciName, i.ingredient.chineseName, String(i.percentage), f.updatedAt.toISOString()])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const csv = rows.map(r => r.map(c => `"${c.replace(/"/g, '""')}"`).join(',')).join('\n')
|
||||
reply.header('Content-Type', 'text/csv; charset=utf-8')
|
||||
reply.header('Content-Disposition', 'attachment; filename=formulas.csv')
|
||||
return reply.send(csv)
|
||||
}
|
||||
|
||||
async function deleteFormula(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
const existing = await prisma.formula.findUnique({ where: { id: request.params.id } })
|
||||
if (!existing) return reply.status(404).send({ error: '配方不存在' })
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const versions = await tx.formulaVersion.findMany({ where: { formulaId: request.params.id }, select: { id: true } })
|
||||
const versionIds = versions.map(v => v.id)
|
||||
|
||||
await tx.formulaIngredient.deleteMany({ where: { formulaVersionId: { in: versionIds } } })
|
||||
await tx.phase.deleteMany({ where: { formulaId: { in: versionIds } } })
|
||||
await tx.formulaVersion.deleteMany({ where: { formulaId: request.params.id } })
|
||||
await tx.formula.delete({ where: { id: request.params.id } })
|
||||
})
|
||||
|
||||
return reply.status(204).send()
|
||||
}
|
||||
|
||||
export async function formulaRoutes(app: FastifyInstance) {
|
||||
app.get('/', listFormulas)
|
||||
app.get('/export', exportFormulas)
|
||||
app.get('/:id', getFormula)
|
||||
app.get('/:id/versions', getVersions)
|
||||
app.post('/', createFormula)
|
||||
app.put('/:id', updateFormula)
|
||||
app.put('/:id/composition', updateComposition)
|
||||
app.delete('/:id', deleteFormula)
|
||||
}
|
||||
7
backend/src/routes/health.ts
Normal file
7
backend/src/routes/health.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export async function healthRoutes(app: FastifyInstance) {
|
||||
app.get('/health', async () => {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() }
|
||||
})
|
||||
}
|
||||
168
backend/src/routes/ingredients.test.ts
Normal file
168
backend/src/routes/ingredients.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { buildApp } from '../app.js'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
let app: FastifyInstance
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp()
|
||||
await app.ready()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
describe('GET /api/ingredients', () => {
|
||||
it('返回成分列表和分页信息', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/ingredients' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json()
|
||||
expect(body.data).toBeDefined()
|
||||
expect(Array.isArray(body.data)).toBe(true)
|
||||
expect(body.pagination).toBeDefined()
|
||||
expect(body.pagination.page).toBe(1)
|
||||
})
|
||||
|
||||
it('支持分页参数', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/ingredients?page=1&limit=5' })
|
||||
const body = res.json()
|
||||
expect(body.pagination.limit).toBe(5)
|
||||
expect(body.data.length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
it('搜索功能匹配 INCI 名称', async () => {
|
||||
await app.inject({
|
||||
method: 'POST', url: '/api/ingredients',
|
||||
payload: { inciName: 'SearchTestABC', chineseName: '搜索测试', functionCategory: 'humectant' },
|
||||
})
|
||||
const res = await app.inject({ method: 'GET', url: '/api/ingredients?search=SearchTestABC' })
|
||||
const body = res.json()
|
||||
expect(body.data.length).toBeGreaterThan(0)
|
||||
expect(body.data[0].inciName).toContain('SearchTestABC')
|
||||
})
|
||||
|
||||
it('搜索功能匹配中文名', async () => {
|
||||
await app.inject({
|
||||
method: 'POST', url: '/api/ingredients',
|
||||
payload: { inciName: 'SearchTest2', chineseName: '独特搜索词测试', functionCategory: 'humectant' },
|
||||
})
|
||||
const res = await app.inject({ method: 'GET', url: '/api/ingredients?search=独特搜索词测试' })
|
||||
const body = res.json()
|
||||
expect(body.data.length).toBeGreaterThan(0)
|
||||
expect(body.data[0].chineseName).toContain('独特搜索词测试')
|
||||
})
|
||||
|
||||
it('按功能分类筛选', async () => {
|
||||
await app.inject({
|
||||
method: 'POST', url: '/api/ingredients',
|
||||
payload: { inciName: 'CategoryTest', chineseName: '分类测试', functionCategory: 'preservative' },
|
||||
})
|
||||
const res = await app.inject({ method: 'GET', url: '/api/ingredients?category=preservative' })
|
||||
const body = res.json()
|
||||
expect(body.data.length).toBeGreaterThan(0)
|
||||
for (const ing of body.data) {
|
||||
expect(ing.functionCategory).toBe('preservative')
|
||||
}
|
||||
})
|
||||
|
||||
it('无效分类返回 400', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/ingredients?category=invalid' })
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/ingredients', () => {
|
||||
it('创建成分成功', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/ingredients',
|
||||
payload: {
|
||||
inciName: 'Test Ingredient',
|
||||
chineseName: '测试成分',
|
||||
functionCategory: 'humectant',
|
||||
unitPrice: 10.5,
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = res.json()
|
||||
expect(body.data.inciName).toBe('Test Ingredient')
|
||||
expect(body.data.id).toBeDefined()
|
||||
})
|
||||
|
||||
it('缺少必填字段返回 400', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/ingredients',
|
||||
payload: { inciName: 'Test' },
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('无效分类返回 400', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/ingredients',
|
||||
payload: {
|
||||
inciName: 'Test', chineseName: '测试', functionCategory: 'invalid',
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('负价格返回 400', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/ingredients',
|
||||
payload: {
|
||||
inciName: 'Test', chineseName: '测试', functionCategory: 'humectant', unitPrice: -1,
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/ingredients/:id', () => {
|
||||
it('更新成分成功', async () => {
|
||||
const list = await app.inject({ method: 'GET', url: '/api/ingredients?search=Test Ingredient' })
|
||||
const id = list.json().data[0].id
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/ingredients/${id}`,
|
||||
payload: { chineseName: '测试成分-已更新', unitPrice: 20 },
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json().data.chineseName).toBe('测试成分-已更新')
|
||||
})
|
||||
|
||||
it('不存在的成分返回 404', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/ingredients/nonexistent-id',
|
||||
payload: { chineseName: 'x' },
|
||||
})
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/ingredients/:id', () => {
|
||||
it('删除未被引用的成分成功', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/ingredients',
|
||||
payload: {
|
||||
inciName: 'ToDelete', chineseName: '待删除', functionCategory: 'other',
|
||||
},
|
||||
})
|
||||
const id = createRes.json().data.id
|
||||
|
||||
const res = await app.inject({ method: 'DELETE', url: `/api/ingredients/${id}` })
|
||||
expect(res.statusCode).toBe(204)
|
||||
})
|
||||
|
||||
it('不存在的成分返回 404', async () => {
|
||||
const res = await app.inject({ method: 'DELETE', url: '/api/ingredients/nonexistent' })
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
})
|
||||
186
backend/src/routes/ingredients.ts
Normal file
186
backend/src/routes/ingredients.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { IngredientCategory } from '../generated/prisma/enums.js'
|
||||
|
||||
const VALID_CATEGORIES = Object.values(IngredientCategory)
|
||||
|
||||
interface IngredientQuery {
|
||||
search?: string
|
||||
category?: string
|
||||
page?: string
|
||||
limit?: string
|
||||
}
|
||||
|
||||
interface IngredientBody {
|
||||
inciName: string
|
||||
chineseName: string
|
||||
functionCategory: string
|
||||
supplier?: string
|
||||
unit?: string
|
||||
unitPrice?: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
async function getIngredients(request: FastifyRequest<{ Querystring: IngredientQuery }>, reply: FastifyReply) {
|
||||
const { search, category, page = '1', limit = '20' } = request.query
|
||||
|
||||
const pageNum = Math.max(1, parseInt(page, 10) || 1)
|
||||
const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10) || 20))
|
||||
|
||||
if (category && !VALID_CATEGORIES.includes(category as IngredientCategory)) {
|
||||
return reply.status(400).send({ error: `无效的功能分类: ${category}` })
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (search && search.length >= 1) {
|
||||
where.OR = [
|
||||
{ inciName: { contains: search, mode: 'insensitive' } },
|
||||
{ chineseName: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
if (category) {
|
||||
where.functionCategory = category
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.ingredient.findMany({
|
||||
where,
|
||||
skip: (pageNum - 1) * limitNum,
|
||||
take: limitNum,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.ingredient.count({ where }),
|
||||
])
|
||||
|
||||
return reply.send({
|
||||
data,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limitNum),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function getIngredient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const ingredient = await prisma.ingredient.findUnique({
|
||||
where: { id: request.params.id },
|
||||
})
|
||||
|
||||
if (!ingredient) {
|
||||
return reply.status(404).send({ error: '成分不存在' })
|
||||
}
|
||||
|
||||
return reply.send({ data: ingredient })
|
||||
}
|
||||
|
||||
async function createIngredient(
|
||||
request: FastifyRequest<{ Body: IngredientBody }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { inciName, chineseName, functionCategory, supplier, unit, unitPrice, description } = request.body
|
||||
|
||||
if (!inciName || !chineseName || !functionCategory) {
|
||||
return reply.status(400).send({ error: 'INCI名称、中文名和功能分类为必填项' })
|
||||
}
|
||||
|
||||
if (!VALID_CATEGORIES.includes(functionCategory as IngredientCategory)) {
|
||||
return reply.status(400).send({ error: `无效的功能分类: ${functionCategory}` })
|
||||
}
|
||||
|
||||
if (unitPrice !== undefined && unitPrice < 0) {
|
||||
return reply.status(400).send({ error: '单价不能为负数' })
|
||||
}
|
||||
|
||||
const ingredient = await prisma.ingredient.create({
|
||||
data: {
|
||||
inciName,
|
||||
chineseName,
|
||||
functionCategory: functionCategory as IngredientCategory,
|
||||
supplier,
|
||||
unit: unit ?? 'kg',
|
||||
unitPrice,
|
||||
description,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.status(201).send({ data: ingredient })
|
||||
}
|
||||
|
||||
async function updateIngredient(
|
||||
request: FastifyRequest<{ Params: { id: string }; Body: Partial<IngredientBody> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const existing = await prisma.ingredient.findUnique({
|
||||
where: { id: request.params.id },
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ error: '成分不存在' })
|
||||
}
|
||||
|
||||
const { functionCategory, unitPrice, ...rest } = request.body
|
||||
|
||||
if (functionCategory && !VALID_CATEGORIES.includes(functionCategory as IngredientCategory)) {
|
||||
return reply.status(400).send({ error: `无效的功能分类: ${functionCategory}` })
|
||||
}
|
||||
|
||||
if (unitPrice !== undefined && unitPrice < 0) {
|
||||
return reply.status(400).send({ error: '单价不能为负数' })
|
||||
}
|
||||
|
||||
const ingredient = await prisma.ingredient.update({
|
||||
where: { id: request.params.id },
|
||||
data: {
|
||||
...rest,
|
||||
...(functionCategory ? { functionCategory: functionCategory as IngredientCategory } : {}),
|
||||
...(unitPrice !== undefined ? { unitPrice } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ data: ingredient })
|
||||
}
|
||||
|
||||
async function deleteIngredient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const existing = await prisma.ingredient.findUnique({
|
||||
where: { id: request.params.id },
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ error: '成分不存在' })
|
||||
}
|
||||
|
||||
const usageCount = await prisma.formulaIngredient.count({
|
||||
where: { ingredientId: request.params.id },
|
||||
})
|
||||
|
||||
if (usageCount > 0) {
|
||||
return reply.status(409).send({
|
||||
error: '该成分已被配方引用,无法删除',
|
||||
usageCount,
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.ingredient.delete({
|
||||
where: { id: request.params.id },
|
||||
})
|
||||
|
||||
return reply.status(204).send()
|
||||
}
|
||||
|
||||
export async function ingredientRoutes(app: FastifyInstance) {
|
||||
app.get('/', getIngredients)
|
||||
app.get('/:id', getIngredient)
|
||||
app.post('/', createIngredient)
|
||||
app.put('/:id', updateIngredient)
|
||||
app.delete('/:id', deleteIngredient)
|
||||
}
|
||||
39
backend/src/routes/projects.ts
Normal file
39
backend/src/routes/projects.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
async function listProjects(_request: FastifyRequest, reply: FastifyReply) {
|
||||
const projects = await prisma.project.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { _count: { select: { formulas: true } } },
|
||||
})
|
||||
return reply.send({ data: projects })
|
||||
}
|
||||
|
||||
async function createProject(request: FastifyRequest<{ Body: { name: string; description?: string } }>, reply: FastifyReply) {
|
||||
const { name, description } = request.body
|
||||
if (!name) return reply.status(400).send({ error: '项目名称为必填项' })
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: { name, description: description ?? null, createdBy: 'system' },
|
||||
})
|
||||
return reply.status(201).send({ data: project })
|
||||
}
|
||||
|
||||
async function updateProject(request: FastifyRequest<{ Params: { id: string }; Body: { name?: string; description?: string } }>, reply: FastifyReply) {
|
||||
const project = await prisma.project.update({
|
||||
where: { id: request.params.id }, data: request.body,
|
||||
})
|
||||
return reply.send({ data: project })
|
||||
}
|
||||
|
||||
async function deleteProject(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
await prisma.project.delete({ where: { id: request.params.id } })
|
||||
return reply.status(204).send()
|
||||
}
|
||||
|
||||
export async function projectRoutes(app: FastifyInstance) {
|
||||
app.get('/', listProjects)
|
||||
app.post('/', createProject)
|
||||
app.put('/:id', updateProject)
|
||||
app.delete('/:id', deleteProject)
|
||||
}
|
||||
15
backend/src/server.ts
Normal file
15
backend/src/server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { buildApp } from './app.js'
|
||||
|
||||
async function start() {
|
||||
const app = await buildApp()
|
||||
const port = Number(process.env.PORT) || 3001
|
||||
|
||||
try {
|
||||
await app.listen({ port, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
start()
|
||||
19
backend/src/services/ai/audit.ts
Normal file
19
backend/src/services/ai/audit.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
|
||||
export async function logAudit(params: {
|
||||
capability: string
|
||||
modelName: string
|
||||
promptHash: string
|
||||
tokensUsed?: number
|
||||
durationMs?: number
|
||||
}): Promise<void> {
|
||||
await prisma.aiAuditLog.create({
|
||||
data: {
|
||||
capability: params.capability,
|
||||
modelName: params.modelName,
|
||||
promptHash: params.promptHash,
|
||||
tokensUsed: params.tokensUsed ?? null,
|
||||
durationMs: params.durationMs ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
37
backend/src/services/ai/cache.ts
Normal file
37
backend/src/services/ai/cache.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
interface CacheEntry<T> {
|
||||
value: T
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export class LRUCache<T> {
|
||||
private cache = new Map<string, CacheEntry<T>>()
|
||||
private maxSize: number
|
||||
|
||||
constructor(maxSize = 100) {
|
||||
this.maxSize = maxSize
|
||||
}
|
||||
|
||||
get(key: string): T | undefined {
|
||||
const entry = this.cache.get(key)
|
||||
if (!entry) return undefined
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key)
|
||||
return undefined
|
||||
}
|
||||
this.cache.delete(key)
|
||||
this.cache.set(key, entry)
|
||||
return entry.value
|
||||
}
|
||||
|
||||
set(key: string, value: T, ttlMs: number): void {
|
||||
if (this.cache.has(key)) this.cache.delete(key)
|
||||
else if (this.cache.size >= this.maxSize) {
|
||||
const first = this.cache.keys().next().value
|
||||
if (first) this.cache.delete(first)
|
||||
}
|
||||
this.cache.set(key, { value, expiresAt: Date.now() + ttlMs })
|
||||
}
|
||||
|
||||
clear(): void { this.cache.clear() }
|
||||
get size(): number { return this.cache.size }
|
||||
}
|
||||
185
backend/src/services/ai/index.ts
Normal file
185
backend/src/services/ai/index.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { createHash } from 'crypto'
|
||||
import { createOpenAIProvider } from './providers/openai.js'
|
||||
import { createDeepSeekProvider } from './providers/deepseek.js'
|
||||
import type { AIProvider, ChatMessage, ChatOptions } from './providers/types.js'
|
||||
import {
|
||||
predictMetricsPrompt, parseNLQueryPrompt, generateFormulaPrompt,
|
||||
recommendColorantsPrompt, extractFormulaPrompt,
|
||||
} from './templates/index.js'
|
||||
import { LRUCache } from './cache.js'
|
||||
import { RateLimiter } from './rate-limiter.js'
|
||||
import { logAudit } from './audit.js'
|
||||
|
||||
const MOCK_RESPONSES: Record<string, string> = {
|
||||
'predict-metrics': JSON.stringify({
|
||||
sensoryIndex: { spreadability: 78, absorption: 82, stickiness: 25, overall: 75 },
|
||||
stabilityScore: 85, costEstimate: 45.5, confidence: 0.85,
|
||||
reasoning: '基于成分组合的模拟预测',
|
||||
}),
|
||||
'parse-nl-query': JSON.stringify({
|
||||
filters: {}, keywords: ['保湿', '精华'], vectorQuery: '高保湿精华配方',
|
||||
}),
|
||||
'generate-formula': JSON.stringify([]),
|
||||
'recommend-colorants': JSON.stringify({ recommendations: [] }),
|
||||
'extract-formula': JSON.stringify({ ingredients: [] }),
|
||||
}
|
||||
|
||||
export class AIService {
|
||||
private providers: Record<string, AIProvider> = {}
|
||||
private cache: LRUCache<string>
|
||||
private rateLimiter: RateLimiter
|
||||
private retryMax: number
|
||||
private defaultModel: string
|
||||
private mockMode: boolean
|
||||
private consecutiveFailures = 0
|
||||
|
||||
constructor() {
|
||||
this.cache = new LRUCache(200)
|
||||
this.rateLimiter = new RateLimiter(10, 10)
|
||||
this.retryMax = 3
|
||||
this.defaultModel = process.env['AI_DEFAULT_MODEL'] ?? 'deepseek-chat'
|
||||
this.mockMode = process.env['AI_MOCK'] === 'true'
|
||||
this.initProviders()
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.providers = {}
|
||||
this.initProviders()
|
||||
this.mockMode = process.env['AI_MOCK'] === 'true'
|
||||
this.consecutiveFailures = 0
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
async testConnection(provider: string): Promise<string> {
|
||||
const p = this.providers[provider]
|
||||
if (!p) throw new Error(`Provider "${provider}" 未配置`)
|
||||
const res = await p.chat([{ role: 'user', content: 'Reply with just "OK"' }], { maxTokens: 5 })
|
||||
return res.model
|
||||
}
|
||||
|
||||
private initProviders(): void {
|
||||
const openaiKey = process.env['OPENAI_API_KEY']
|
||||
const deepseekKey = process.env['DEEPSEEK_API_KEY']
|
||||
if (openaiKey) {
|
||||
this.providers['openai'] = createOpenAIProvider(openaiKey, process.env['OPENAI_BASE_URL'])
|
||||
}
|
||||
if (deepseekKey) {
|
||||
this.providers['deepseek'] = createDeepSeekProvider(deepseekKey, process.env['DEEPSEEK_BASE_URL'])
|
||||
}
|
||||
}
|
||||
|
||||
private selectProvider(model?: string): { provider: AIProvider; model: string } {
|
||||
const m = model ?? this.defaultModel
|
||||
if (m.startsWith('gpt-') || m.startsWith('o1') || m.startsWith('o3')) {
|
||||
const p = this.providers['openai'] ?? this.providers['deepseek']
|
||||
if (!p) throw new Error('No AI provider configured')
|
||||
return { provider: p, model: m }
|
||||
}
|
||||
const p = this.providers['deepseek'] ?? this.providers['openai']
|
||||
if (!p) throw new Error('No AI provider configured')
|
||||
return { provider: p, model: m }
|
||||
}
|
||||
|
||||
private hash(...inputs: string[]): string {
|
||||
return createHash('sha256').update(inputs.join('|')).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
async predictMetrics(ingredients: Array<{ name: string; percentage: number; category: string }>): Promise<string> {
|
||||
return this.execute('predict-metrics', predictMetricsPrompt(ingredients), { ttlMs: 3600_000 })
|
||||
}
|
||||
|
||||
async parseNLQuery(query: string): Promise<string> {
|
||||
return this.execute('parse-nl-query', parseNLQueryPrompt(query), { ttlMs: 300_000 })
|
||||
}
|
||||
|
||||
async generateFormula(constraints: Parameters<typeof generateFormulaPrompt>[0]): Promise<string> {
|
||||
return this.execute('generate-formula', generateFormulaPrompt(constraints), { ttlMs: 0 })
|
||||
}
|
||||
|
||||
async recommendColorants(targetLab: { L: number; a: number; b: number }): Promise<string> {
|
||||
return this.execute('recommend-colorants', recommendColorantsPrompt(targetLab), { ttlMs: 1800_000 })
|
||||
}
|
||||
|
||||
async extractFormula(text: string): Promise<string> {
|
||||
return this.execute('extract-formula', extractFormulaPrompt(text), { ttlMs: 0 })
|
||||
}
|
||||
|
||||
async chatStream(capability: string, messages: ChatMessage[], options?: ChatOptions): Promise<AsyncIterable<string>> {
|
||||
if (this.mockMode) {
|
||||
const mock = MOCK_RESPONSES[capability]
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
if (mock) yield mock
|
||||
},
|
||||
}
|
||||
}
|
||||
const { provider, model } = this.selectProvider(options?.model)
|
||||
return provider.chatStream(messages, { ...options, model })
|
||||
}
|
||||
|
||||
private async execute(
|
||||
capability: string, messages: ChatMessage[],
|
||||
opts: { ttlMs: number; model?: string },
|
||||
): Promise<string> {
|
||||
const promptHash = this.hash(capability, JSON.stringify(messages))
|
||||
|
||||
if (this.consecutiveFailures >= 3) {
|
||||
return this.fallback(capability)
|
||||
}
|
||||
|
||||
if (opts.ttlMs > 0) {
|
||||
const cached = this.cache.get(promptHash)
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
if (this.mockMode) {
|
||||
return MOCK_RESPONSES[capability] ?? '{}'
|
||||
}
|
||||
|
||||
await this.rateLimiter.acquire()
|
||||
|
||||
const { provider, model } = this.selectProvider(opts.model)
|
||||
const start = Date.now()
|
||||
|
||||
for (let attempt = 0; attempt < this.retryMax; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||
|
||||
const res = await provider.chat(messages, {
|
||||
model,
|
||||
temperature: 0.5,
|
||||
maxTokens: 2000,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
this.consecutiveFailures = 0
|
||||
|
||||
const duration = Date.now() - start
|
||||
logAudit({
|
||||
capability, modelName: model, promptHash,
|
||||
tokensUsed: res.usage?.totalTokens, durationMs: duration,
|
||||
}).catch(() => {})
|
||||
|
||||
if (opts.ttlMs > 0) {
|
||||
this.cache.set(promptHash, res.content, opts.ttlMs)
|
||||
}
|
||||
|
||||
return res.content
|
||||
} catch (err) {
|
||||
if (attempt < this.retryMax - 1) {
|
||||
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.consecutiveFailures++
|
||||
return this.fallback(capability)
|
||||
}
|
||||
|
||||
private fallback(capability: string): string {
|
||||
return MOCK_RESPONSES[capability] ?? '{}'
|
||||
}
|
||||
}
|
||||
|
||||
export const aiService = new AIService()
|
||||
5
backend/src/services/ai/providers/deepseek.ts
Normal file
5
backend/src/services/ai/providers/deepseek.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createOpenAIProvider } from './openai.js'
|
||||
|
||||
export function createDeepSeekProvider(apiKey: string, baseURL?: string) {
|
||||
return createOpenAIProvider(apiKey, baseURL ?? 'https://api.deepseek.com/v1', 'deepseek-chat')
|
||||
}
|
||||
88
backend/src/services/ai/providers/openai.ts
Normal file
88
backend/src/services/ai/providers/openai.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { AIProvider, ChatMessage, ChatOptions, ChatResponse } from './types.js'
|
||||
|
||||
export function createOpenAIProvider(apiKey: string, baseURL?: string, defaultModel = 'gpt-4o'): AIProvider {
|
||||
const endpoint = `${baseURL ?? 'https://api.openai.com/v1'}/chat/completions`
|
||||
|
||||
async function chat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResponse> {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: options?.model ?? defaultModel,
|
||||
messages,
|
||||
temperature: options?.temperature ?? 0.7,
|
||||
max_tokens: options?.maxTokens ?? 2000,
|
||||
stream: false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
throw new Error(`AI API error (${res.status}): ${err}`)
|
||||
}
|
||||
|
||||
const json = await res.json() as Record<string, unknown>
|
||||
const choice = (json.choices as Array<Record<string, unknown>>)?.[0]
|
||||
const msg = choice?.message as Record<string, unknown> | undefined
|
||||
return {
|
||||
content: (msg?.content as string) ?? '',
|
||||
model: json.model as string ?? 'unknown',
|
||||
usage: json.usage as ChatResponse['usage'],
|
||||
}
|
||||
}
|
||||
|
||||
async function* chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string> {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: options?.model ?? defaultModel,
|
||||
messages,
|
||||
temperature: options?.temperature ?? 0.7,
|
||||
max_tokens: options?.maxTokens ?? 2000,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
throw new Error(`AI API stream error (${res.status}): ${err}`)
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) throw new Error('No response body')
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('data: ')) continue
|
||||
const data = trimmed.slice(6)
|
||||
if (data === '[DONE]') return
|
||||
|
||||
try {
|
||||
const json = JSON.parse(data) as Record<string, unknown>
|
||||
const delta = (json.choices as Array<Record<string, unknown>>)?.[0]?.delta as Record<string, unknown> | undefined
|
||||
if (delta?.content) yield delta.content as string
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { chat, chatStream }
|
||||
}
|
||||
22
backend/src/services/ai/providers/types.ts
Normal file
22
backend/src/services/ai/providers/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
model?: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
stream?: boolean
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
content: string
|
||||
model: string
|
||||
usage?: { promptTokens: number; completionTokens: number; totalTokens: number }
|
||||
}
|
||||
|
||||
export interface AIProvider {
|
||||
chat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResponse>
|
||||
chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string>
|
||||
}
|
||||
31
backend/src/services/ai/rate-limiter.ts
Normal file
31
backend/src/services/ai/rate-limiter.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export class RateLimiter {
|
||||
private tokens: number
|
||||
private maxTokens: number
|
||||
private refillRate: number
|
||||
private lastRefill: number
|
||||
|
||||
constructor(maxTokens = 10, refillPerSecond = 10) {
|
||||
this.tokens = maxTokens
|
||||
this.maxTokens = maxTokens
|
||||
this.refillRate = refillPerSecond
|
||||
this.lastRefill = Date.now()
|
||||
}
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
this.refill()
|
||||
if (this.tokens > 0) {
|
||||
this.tokens--
|
||||
return
|
||||
}
|
||||
const waitMs = (1 / this.refillRate) * 1000
|
||||
await new Promise(resolve => setTimeout(resolve, waitMs))
|
||||
return this.acquire()
|
||||
}
|
||||
|
||||
private refill(): void {
|
||||
const now = Date.now()
|
||||
const elapsed = (now - this.lastRefill) / 1000
|
||||
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate)
|
||||
this.lastRefill = now
|
||||
}
|
||||
}
|
||||
44
backend/src/services/ai/templates/index.ts
Normal file
44
backend/src/services/ai/templates/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ChatMessage } from '../providers/types.js'
|
||||
|
||||
export function predictMetricsPrompt(ingredients: Array<{ name: string; percentage: number; category: string }>): ChatMessage[] {
|
||||
const ingList = ingredients.map(i => `- ${i.name} (${i.category}): ${i.percentage}%`).join('\n')
|
||||
return [
|
||||
{ role: 'system', content: '你是一名资深化妆品配方工程师。根据成分列表预测配方的肤感指数、稳定性评分和成本估算。返回 JSON 格式:{"sensoryIndex":{"spreadability":0-100,"absorption":0-100,"stickiness":0-100,"overall":0-100},"stabilityScore":0-100,"costEstimate":元/kg,"confidence":0-1,"reasoning":"简短理由"}' },
|
||||
{ role: 'user', content: `请分析以下配方的指标:\n${ingList}` },
|
||||
]
|
||||
}
|
||||
|
||||
export function parseNLQueryPrompt(query: string): ChatMessage[] {
|
||||
return [
|
||||
{ role: 'system', content: '将用户的自然语言查询转换为结构化搜索条件。返回 JSON:{"filters":{"excludeIngredients":["成分名"],"includeIngredients":["成分名"],"categories":["分类"]},"keywords":["关键词"],"vectorQuery":"语义搜索词"}' },
|
||||
{ role: 'user', content: query },
|
||||
]
|
||||
}
|
||||
|
||||
export function generateFormulaPrompt(constraints: {
|
||||
baseFormulaName?: string
|
||||
baseIngredients?: Array<{ name: string; percentage: number }>
|
||||
costLimit?: number
|
||||
keepIngredients?: string[]
|
||||
excludeIngredients?: string[]
|
||||
targetMetrics?: Record<string, number>
|
||||
}): ChatMessage[] {
|
||||
return [
|
||||
{ role: 'system', content: '你是一名资深化妆品配方工程师。根据约束条件生成优化的配方方案。返回 JSON 数组,每个方案包含:{"name":"方案名","changes":[{"action":"add/remove/adjust","ingredient":"成分名","oldPercentage":null|number,"newPercentage":number}],"predictedMetrics":{},"reasoning":"理由"}' },
|
||||
{ role: 'user', content: `约束条件:${JSON.stringify(constraints, null, 2)}` },
|
||||
]
|
||||
}
|
||||
|
||||
export function recommendColorantsPrompt(targetLab: { L: number; a: number; b: number }): ChatMessage[] {
|
||||
return [
|
||||
{ role: 'system', content: '你是一名化妆品色彩专家。根据目标 Lab 颜色值推荐色浆组合及比例。返回 JSON:{"recommendations":[{"colorants":[{"name":"色浆名","ratio":0-1}],"predictedDeltaE":number,"confidence":0-1}]}' },
|
||||
{ role: 'user', content: `目标颜色 Lab(${targetLab.L}, ${targetLab.a}, ${targetLab.b})` },
|
||||
]
|
||||
}
|
||||
|
||||
export function extractFormulaPrompt(text: string): ChatMessage[] {
|
||||
return [
|
||||
{ role: 'system', content: '从配方文本中提取结构化数据。返回 JSON:{"ingredients":[{"inciName":"INCI名","chineseName":"中文名","percentage":number,"phase":"相名","processNotes":"工艺备注"}]}' },
|
||||
{ role: 'user', content: text },
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user