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:
qichi.liang
2026-05-20 17:50:37 +08:00
commit 23e5cb4006
125 changed files with 14454 additions and 0 deletions

15
backend/.env.example Normal file
View File

@@ -0,0 +1,15 @@
# 服务端口
PORT=3001
# PostgreSQL 数据库
DATABASE_URL=postgresql://colorfull:colorfull@localhost:5432/colorfull
# JWT 密钥(生产环境请使用强随机字符串)
JWT_SECRET=dev-secret-change-in-production
# AI API Keys
OPENAI_API_KEY=sk-your-key-here
DEEPSEEK_API_KEY=sk-your-key-here
# AI Mock 模式(开发环境设为 true 跳过真实 API 调用)
AI_MOCK=false

5
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/src/generated/prisma

1
backend/.npmrc Normal file
View File

@@ -0,0 +1 @@
registry=https://registry.npmmirror.com

34
backend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "color-full-backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"db:migrate": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"test": "vitest run"
},
"dependencies": {
"@fastify/cors": "^11.1.0",
"@fastify/env": "^5.0.0",
"@fastify/formbody": "^8.0.0",
"@fastify/multipart": "^9.0.0",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@types/pg": "^8.20.0",
"fastify": "^5.4.0",
"pg": "^8.21.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"pino-pretty": "^13.1.3",
"prisma": "^7.8.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0",
"vitest": "^4.1.6"
}
}

2486
backend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
allowBuilds:
'@prisma/engines': true
esbuild: true
prisma: true

14
backend/prisma.config.ts Normal file
View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View File

@@ -0,0 +1,198 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
CREATE EXTENSION IF NOT EXISTS vector;
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('engineer', 'admin');
-- CreateEnum
CREATE TYPE "IngredientCategory" AS ENUM ('emulsifier', 'humectant', 'thickener', 'preservative', 'antioxidant', 'fragrance', 'colorant', 'ph_adjuster', 'sunscreen', 'surfactant', 'emollient', 'other');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'engineer',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "projects" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"created_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "formulas" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"project_id" TEXT,
"current_version" INTEGER NOT NULL DEFAULT 1,
"created_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"embedding" vector(1536),
CONSTRAINT "formulas_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "formula_versions" (
"id" TEXT NOT NULL,
"formula_id" TEXT NOT NULL,
"version_number" INTEGER NOT NULL,
"description" TEXT,
"snapshot_data" JSONB NOT NULL,
"created_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "formula_versions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ingredients" (
"id" TEXT NOT NULL,
"inci_name" TEXT NOT NULL,
"chinese_name" TEXT NOT NULL,
"function_category" "IngredientCategory" NOT NULL,
"supplier" TEXT,
"unit" TEXT NOT NULL DEFAULT 'kg',
"unit_price" DECIMAL(10,2),
"description" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ingredients_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "phases" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"formula_id" TEXT NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "phases_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "formula_ingredients" (
"id" TEXT NOT NULL,
"formula_version_id" TEXT NOT NULL,
"phase_id" TEXT,
"ingredient_id" TEXT NOT NULL,
"percentage" DECIMAL(5,2) NOT NULL,
"process_notes" TEXT,
CONSTRAINT "formula_ingredients_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "color_formulas" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"target_lab" JSONB NOT NULL,
"actual_lab" JSONB,
"delta_e" DOUBLE PRECISION,
"colorant_composition" JSONB,
"formula_id" TEXT,
"created_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "color_formulas_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_audit_logs" (
"id" TEXT NOT NULL,
"capability" TEXT NOT NULL,
"model_name" TEXT NOT NULL,
"prompt_hash" TEXT NOT NULL,
"tokens_used" INTEGER,
"duration_ms" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
-- CreateIndex
CREATE INDEX "formulas_project_id_idx" ON "formulas"("project_id");
-- CreateIndex
CREATE INDEX "formulas_created_by_idx" ON "formulas"("created_by");
-- CreateIndex
CREATE INDEX "formula_versions_formula_id_idx" ON "formula_versions"("formula_id");
-- CreateIndex
CREATE UNIQUE INDEX "formula_versions_formula_id_version_number_key" ON "formula_versions"("formula_id", "version_number");
-- CreateIndex
CREATE INDEX "ingredients_function_category_idx" ON "ingredients"("function_category");
-- CreateIndex
CREATE INDEX "phases_formula_id_idx" ON "phases"("formula_id");
-- CreateIndex
CREATE INDEX "formula_ingredients_formula_version_id_idx" ON "formula_ingredients"("formula_version_id");
-- CreateIndex
CREATE INDEX "formula_ingredients_ingredient_id_idx" ON "formula_ingredients"("ingredient_id");
-- CreateIndex
CREATE INDEX "color_formulas_formula_id_idx" ON "color_formulas"("formula_id");
-- CreateIndex
CREATE INDEX "color_formulas_created_by_idx" ON "color_formulas"("created_by");
-- CreateIndex
CREATE INDEX "ai_audit_logs_capability_idx" ON "ai_audit_logs"("capability");
-- CreateIndex
CREATE INDEX "ai_audit_logs_created_at_idx" ON "ai_audit_logs"("created_at");
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "formulas" ADD CONSTRAINT "formulas_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "formulas" ADD CONSTRAINT "formulas_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "formula_versions" ADD CONSTRAINT "formula_versions_formula_id_fkey" FOREIGN KEY ("formula_id") REFERENCES "formulas"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "formula_versions" ADD CONSTRAINT "formula_versions_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "phases" ADD CONSTRAINT "phases_formula_id_fkey" FOREIGN KEY ("formula_id") REFERENCES "formula_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "formula_ingredients" ADD CONSTRAINT "formula_ingredients_formula_version_id_fkey" FOREIGN KEY ("formula_version_id") REFERENCES "formula_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "formula_ingredients" ADD CONSTRAINT "formula_ingredients_phase_id_fkey" FOREIGN KEY ("phase_id") REFERENCES "phases"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "formula_ingredients" ADD CONSTRAINT "formula_ingredients_ingredient_id_fkey" FOREIGN KEY ("ingredient_id") REFERENCES "ingredients"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "color_formulas" ADD CONSTRAINT "color_formulas_formula_id_fkey" FOREIGN KEY ("formula_id") REFERENCES "formulas"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "color_formulas" ADD CONSTRAINT "color_formulas_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1 @@
CREATE INDEX IF NOT EXISTS formulas_embedding_idx ON formulas USING hnsw (embedding vector_cosine_ops);

View File

@@ -0,0 +1,2 @@
# Please do not edit this file manually
provider = "postgresql"

View File

@@ -0,0 +1,176 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
enum UserRole {
engineer
admin
}
enum IngredientCategory {
emulsifier
humectant
thickener
preservative
antioxidant
fragrance
colorant
ph_adjuster
sunscreen
surfactant
emollient
other
}
model User {
id String @id @default(uuid())
username String @unique
passwordHash String @map("password_hash")
role UserRole @default(engineer)
createdAt DateTime @default(now()) @map("created_at")
formulas Formula[] @relation("FormulaCreator")
colorFormulas ColorFormula[] @relation("ColorFormulaCreator")
formulaVersions FormulaVersion[]
projects Project[]
@@map("users")
}
model Project {
id String @id @default(uuid())
name String
description String?
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
creator User @relation(fields: [createdBy], references: [id])
formulas Formula[]
@@map("projects")
}
model Formula {
id String @id @default(uuid())
name String
description String?
projectId String? @map("project_id")
currentVersion Int @default(1) @map("current_version")
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
embedding Unsupported("vector(1536)")?
project Project? @relation(fields: [projectId], references: [id])
creator User @relation("FormulaCreator", fields: [createdBy], references: [id])
versions FormulaVersion[]
colorFormulas ColorFormula[]
@@index([projectId])
@@index([createdBy])
@@map("formulas")
}
model FormulaVersion {
id String @id @default(uuid())
formulaId String @map("formula_id")
versionNumber Int @map("version_number")
description String?
snapshotData Json @map("snapshot_data")
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
formula Formula @relation(fields: [formulaId], references: [id])
creator User @relation(fields: [createdBy], references: [id])
phases Phase[]
ingredients FormulaIngredient[]
@@unique([formulaId, versionNumber])
@@index([formulaId])
@@map("formula_versions")
}
model Ingredient {
id String @id @default(uuid())
inciName String @map("inci_name")
chineseName String @map("chinese_name")
functionCategory IngredientCategory @map("function_category")
supplier String?
unit String @default("kg")
unitPrice Decimal? @map("unit_price") @db.Decimal(10, 2)
description String?
createdAt DateTime @default(now()) @map("created_at")
formulaIngredients FormulaIngredient[]
@@index([functionCategory])
@@map("ingredients")
}
model Phase {
id String @id @default(uuid())
name String
formulaId String @map("formula_id")
sortOrder Int @default(0) @map("sort_order")
formulaVersion FormulaVersion @relation(fields: [formulaId], references: [id])
ingredients FormulaIngredient[]
@@index([formulaId])
@@map("phases")
}
model FormulaIngredient {
id String @id @default(uuid())
formulaVersionId String @map("formula_version_id")
phaseId String? @map("phase_id")
ingredientId String @map("ingredient_id")
percentage Decimal @db.Decimal(5, 2)
processNotes String? @map("process_notes")
formulaVersion FormulaVersion @relation(fields: [formulaVersionId], references: [id])
phase Phase? @relation(fields: [phaseId], references: [id])
ingredient Ingredient @relation(fields: [ingredientId], references: [id])
@@index([formulaVersionId])
@@index([ingredientId])
@@map("formula_ingredients")
}
model ColorFormula {
id String @id @default(uuid())
name String
targetLab Json @map("target_lab")
actualLab Json? @map("actual_lab")
deltaE Float? @map("delta_e")
colorantComposition Json? @map("colorant_composition")
formulaId String? @map("formula_id")
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
formula Formula? @relation(fields: [formulaId], references: [id])
creator User @relation("ColorFormulaCreator", fields: [createdBy], references: [id])
@@index([formulaId])
@@index([createdBy])
@@map("color_formulas")
}
model AiAuditLog {
id String @id @default(uuid())
capability String
modelName String @map("model_name")
promptHash String @map("prompt_hash")
tokensUsed Int? @map("tokens_used")
durationMs Int? @map("duration_ms")
createdAt DateTime @default(now()) @map("created_at")
@@index([capability])
@@index([createdAt])
@@map("ai_audit_logs")
}

66
backend/prisma/seed.ts Normal file
View File

@@ -0,0 +1,66 @@
import { prisma } from '../src/lib/prisma.js'
import { IngredientCategory } from '../src/generated/prisma/enums.js'
const ingredients = [
{ inciName: 'Glycerin', chineseName: '甘油', functionCategory: IngredientCategory.humectant, supplier: '丰益油脂', unitPrice: 15.00, description: '最常用的保湿剂,吸湿性强' },
{ inciName: 'Butylene Glycol', chineseName: '丁二醇', functionCategory: IngredientCategory.humectant, supplier: '大赛璐', unitPrice: 35.00, description: '多功能保湿剂,兼有防腐增效作用' },
{ inciName: 'Propylene Glycol', chineseName: '丙二醇', functionCategory: IngredientCategory.humectant, supplier: '陶氏化学', unitPrice: 20.00, description: '保湿剂和溶剂' },
{ inciName: 'Sodium Hyaluronate', chineseName: '透明质酸钠', functionCategory: IngredientCategory.humectant, supplier: '华熙生物', unitPrice: 2800.00, description: '高分子保湿剂,锁水能力极强' },
{ inciName: 'Panthenol', chineseName: '泛醇', functionCategory: IngredientCategory.humectant, supplier: '巴斯夫', unitPrice: 180.00, description: '维生素B5前体保湿修复' },
{ inciName: 'Trehalose', chineseName: '海藻糖', functionCategory: IngredientCategory.humectant, supplier: '林原', unitPrice: 120.00, description: '天然保湿因子,保护细胞膜' },
{ inciName: 'Betaine', chineseName: '甜菜碱', functionCategory: IngredientCategory.humectant, supplier: '杜邦', unitPrice: 60.00, description: '天然氨基酸保湿剂' },
{ inciName: 'Cetearyl Alcohol', chineseName: '鲸蜡硬脂醇', functionCategory: IngredientCategory.emulsifier, supplier: '巴斯夫', unitPrice: 25.00, description: 'O/W型乳化剂和增稠剂' },
{ inciName: 'Glyceryl Stearate', chineseName: '甘油硬脂酸酯', functionCategory: IngredientCategory.emulsifier, supplier: '禾大', unitPrice: 30.00, description: '温和乳化剂,自乳化型' },
{ inciName: 'Ceteareth-20', chineseName: '鲸蜡硬脂醇聚醚-20', functionCategory: IngredientCategory.emulsifier, supplier: '巴斯夫', unitPrice: 40.00, description: 'O/W型高效乳化剂' },
{ inciName: 'Polysorbate 60', chineseName: '聚山梨醇酯-60', functionCategory: IngredientCategory.emulsifier, supplier: '禾大', unitPrice: 35.00, description: '亲水性乳化剂' },
{ inciName: 'Lecithin', chineseName: '卵磷脂', functionCategory: IngredientCategory.emulsifier, supplier: '嘉吉', unitPrice: 200.00, description: '天然磷脂乳化剂,仿生亲肤' },
{ inciName: 'Carbomer', chineseName: '卡波姆', functionCategory: IngredientCategory.thickener, supplier: '路博润', unitPrice: 80.00, description: '高效增稠剂,需中和' },
{ inciName: 'Xanthan Gum', chineseName: '黄原胶', functionCategory: IngredientCategory.thickener, supplier: 'CP Kelco', unitPrice: 65.00, description: '天然多糖增稠剂,耐电解质' },
{ inciName: 'Hydroxyethylcellulose', chineseName: '羟乙基纤维素', functionCategory: IngredientCategory.thickener, supplier: '亚什兰', unitPrice: 90.00, description: '非离子增稠剂,透明度好' },
{ inciName: 'Phenoxyethanol', chineseName: '苯氧乙醇', functionCategory: IngredientCategory.preservative, supplier: '巴斯夫', unitPrice: 55.00, description: '广谱防腐剂,最常用之一' },
{ inciName: 'Sodium Benzoate', chineseName: '苯甲酸钠', functionCategory: IngredientCategory.preservative, supplier: '帝斯曼', unitPrice: 18.00, description: '食品级防腐剂,酸性环境有效' },
{ inciName: 'Ethylhexylglycerin', chineseName: '乙基己基甘油', functionCategory: IngredientCategory.preservative, supplier: '舒美', unitPrice: 220.00, description: '多功能防腐增效剂' },
{ inciName: 'Tocopherol', chineseName: '生育酚维生素E', functionCategory: IngredientCategory.antioxidant, supplier: '帝斯曼', unitPrice: 320.00, description: '天然抗氧化剂,油溶性' },
{ inciName: 'Ascorbic Acid', chineseName: '抗坏血酸维生素C', functionCategory: IngredientCategory.antioxidant, supplier: '帝斯曼', unitPrice: 450.00, description: '强效抗氧化剂,美白功效' },
{ inciName: 'BHT', chineseName: '丁羟甲苯', functionCategory: IngredientCategory.antioxidant, supplier: '伊士曼', unitPrice: 40.00, description: '合成抗氧化剂,油脂保护' },
{ inciName: 'Dimethicone', chineseName: '聚二甲基硅氧烷', functionCategory: IngredientCategory.emollient, supplier: '道康宁', unitPrice: 45.00, description: '硅油,丝滑肤感' },
{ inciName: 'Caprylic/Capric Triglyceride', chineseName: '辛酸/癸酸甘油三酯', functionCategory: IngredientCategory.emollient, supplier: '巴斯夫', unitPrice: 50.00, description: '轻质润肤酯,铺展性好' },
{ inciName: 'Squalane', chineseName: '角鲨烷', functionCategory: IngredientCategory.emollient, supplier: 'Amyris', unitPrice: 280.00, description: '植物角鲨烷,亲肤极佳' },
{ inciName: 'Isopropyl Myristate', chineseName: '肉豆蔻酸异丙酯', functionCategory: IngredientCategory.emollient, supplier: '禾大', unitPrice: 30.00, description: '渗透促进剂和润肤酯' },
{ inciName: 'Jojoba Oil', chineseName: '霍霍巴籽油', functionCategory: IngredientCategory.emollient, supplier: '德之馨', unitPrice: 150.00, description: '液态蜡酯,与皮脂相似' },
{ inciName: 'Sodium Laureth Sulfate', chineseName: '月桂醇聚醚硫酸酯钠', functionCategory: IngredientCategory.surfactant, supplier: '巴斯夫', unitPrice: 12.00, description: '阴离子表面活性剂,起泡清洁' },
{ inciName: 'Cocamidopropyl Betaine', chineseName: '椰油酰胺丙基甜菜碱', functionCategory: IngredientCategory.surfactant, supplier: '索尔维', unitPrice: 22.00, description: '两性表面活性剂,温和清洁' },
{ inciName: 'Decyl Glucoside', chineseName: '癸基葡糖苷', functionCategory: IngredientCategory.surfactant, supplier: '巴斯夫', unitPrice: 70.00, description: '非离子APG表活极温和' },
{ inciName: 'CI 77491', chineseName: '氧化铁红', functionCategory: IngredientCategory.colorant, supplier: '森馨', unitPrice: 85.00, description: '无机颜料,红色系' },
{ inciName: 'CI 77492', chineseName: '氧化铁黄', functionCategory: IngredientCategory.colorant, supplier: '森馨', unitPrice: 85.00, description: '无机颜料,黄色系' },
{ inciName: 'CI 77499', chineseName: '氧化铁黑', functionCategory: IngredientCategory.colorant, supplier: '森馨', unitPrice: 85.00, description: '无机颜料,黑色系' },
{ inciName: 'CI 77891', chineseName: '二氧化钛', functionCategory: IngredientCategory.colorant, supplier: '科慕', unitPrice: 40.00, description: '白色颜料和物理防晒剂' },
{ inciName: 'Mica', chineseName: '云母', functionCategory: IngredientCategory.colorant, supplier: '默克', unitPrice: 120.00, description: '珠光颜料基材' },
{ inciName: 'Citric Acid', chineseName: '柠檬酸', functionCategory: IngredientCategory.ph_adjuster, supplier: '英轩', unitPrice: 10.00, description: '酸性pH调节剂果酸类' },
{ inciName: 'Sodium Hydroxide', chineseName: '氢氧化钠', functionCategory: IngredientCategory.ph_adjuster, supplier: '万华化学', unitPrice: 8.00, description: '碱性pH调节剂' },
{ inciName: 'Triethanolamine', chineseName: '三乙醇胺', functionCategory: IngredientCategory.ph_adjuster, supplier: '陶氏化学', unitPrice: 20.00, description: 'pH调节剂和中和剂' },
{ inciName: 'Ethylhexyl Methoxycinnamate', chineseName: '甲氧基肉桂酸乙基己酯', functionCategory: IngredientCategory.sunscreen, supplier: '巴斯夫', unitPrice: 65.00, description: 'UVB化学防晒剂' },
{ inciName: 'Bis-Ethylhexyloxyphenol Methoxyphenyl Triazine', chineseName: '双-乙基己氧苯酚甲氧苯基三嗪', functionCategory: IngredientCategory.sunscreen, supplier: '巴斯夫', unitPrice: 550.00, description: '广谱UVA/UVB防晒剂Tinosorb S' },
{ inciName: 'Parfum', chineseName: '香精', functionCategory: IngredientCategory.fragrance, supplier: '奇华顿', unitPrice: 300.00, description: '调配香精' },
]
async function main() {
console.log('开始导入成分种子数据...')
await prisma.ingredient.createMany({
data: ingredients,
skipDuplicates: true,
})
const count = await prisma.ingredient.count()
console.log(`成分种子数据导入完成!共 ${count} 条记录`)
}
main()
.catch((e) => {
console.error('种子数据导入失败:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

48
backend/src/app.ts Normal file
View 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
}

View 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
View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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)
})
})

View 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)
}

View 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() }
})
}

View 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)
})
})

View 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)
}

View 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
View 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()

View 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,
},
})
}

View 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 }
}

View 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()

View 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')
}

View 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 }
}

View 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>
}

View 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
}
}

View 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 },
]
}

19
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es2023",
"module": "esnext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"],
"resolveJsonModule": true,
"declaration": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/app.ts","./src/server.ts","./src/generated/prisma/browser.ts","./src/generated/prisma/client.ts","./src/generated/prisma/commonInputTypes.ts","./src/generated/prisma/enums.ts","./src/generated/prisma/models.ts","./src/generated/prisma/internal/class.ts","./src/generated/prisma/internal/prismaNamespace.ts","./src/generated/prisma/internal/prismaNamespaceBrowser.ts","./src/generated/prisma/models/AiAuditLog.ts","./src/generated/prisma/models/ColorFormula.ts","./src/generated/prisma/models/Formula.ts","./src/generated/prisma/models/FormulaIngredient.ts","./src/generated/prisma/models/FormulaVersion.ts","./src/generated/prisma/models/Ingredient.ts","./src/generated/prisma/models/Phase.ts","./src/generated/prisma/models/Project.ts","./src/generated/prisma/models/User.ts","./src/lib/prisma.ts","./src/routes/ai.ts","./src/routes/auth.ts","./src/routes/color.ts","./src/routes/config.ts","./src/routes/formulas.test.ts","./src/routes/formulas.ts","./src/routes/health.ts","./src/routes/ingredients.test.ts","./src/routes/ingredients.ts","./src/routes/projects.ts","./src/services/ai/audit.ts","./src/services/ai/cache.ts","./src/services/ai/index.ts","./src/services/ai/rate-limiter.ts","./src/services/ai/providers/deepseek.ts","./src/services/ai/providers/openai.ts","./src/services/ai/providers/types.ts","./src/services/ai/templates/index.ts"],"version":"5.9.3"}

8
backend/vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
globals: false,
},
})