企业级重构:四层模块化架构 + RBAC授权 + 安全加固 + 颜色引擎/配方推演增强

架构
- 后端从 flat routes/ 重构为 modules/<domain>/ 模块化结构(8个模块)
- 四层架构:Route -> Service -> Repository -> Prisma
- 新增 shared/ 基础设施(AppError 异常体系、ALS 上下文、prom-client 指标)
- 前端 Toast/Skeleton/Alert 组件基建 + formulaService 模板

安全
- JWT 签名算法修复(HS256 用 createHmac 而非 createHash)
- 密码哈希 async scrypt + timingSafeEqual
- API Key 从 localStorage 迁移至服务端 runtime/config.json
- Helmet 安全头 + rate-limit 全局限流 100 req/min
- 全局 auth preHandler + RBAC + Ownership 中间件

颜色引擎
- 色匹配切换为 cube 粗筛 + CIEDE2000 精排
- PantoneColor 表 + 种子数据 + 搜索端点
- AI 配色 Prompt 注入成分库 colorant 列表

配方推演
- 本地优化引擎(同 category 替换 + 成本排序)
- baseFormulaId 支持 + Pareto 散点图

文档
- ADR-0003 四层架构、ADR-0004 RBAC 授权模型
- 更新 ADR-0001/0002
- api-reference.md(29端点)、project-overview.md

部署
- Dockerfile * 2 + nginx.conf + docker-compose.prod.yml
- 健康探针 + 优雅关闭 + pg_dump 备份脚本
- ESLint + Prettier + tsconfig strict
This commit is contained in:
qichi.liang
2026-05-21 17:29:52 +08:00
parent 5240505a2e
commit c58ca26969
99 changed files with 6275 additions and 1353 deletions

7
backend/.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"tabWidth": 2
}

19
backend/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:24-alpine AS builder
WORKDIR /app
RUN corepack enable
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
RUN pnpm fetch
RUN pnpm install --frozen-lockfile --offline
COPY . .
RUN pnpm exec prisma generate
RUN pnpm build
FROM node:24-alpine
WORKDIR /app
RUN corepack enable
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/package.json ./
EXPOSE 3001
CMD ["node", "dist/server.js"]

16
backend/eslint.config.js Normal file
View File

@@ -0,0 +1,16 @@
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ['dist/**', 'node_modules/**', 'src/generated/**'],
},
{
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
)

View File

@@ -10,25 +10,41 @@
"db:migrate": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"test": "vitest run"
"test": "vitest run",
"lint": "eslint src/",
"format": "prettier --write src/",
"api:gen": "tsx scripts/generate-openapi.ts"
},
"dependencies": {
"@fastify/cors": "^11.1.0",
"@fastify/env": "^5.0.0",
"@fastify/formbody": "^8.0.0",
"@fastify/helmet": "^13.0.2",
"@fastify/multipart": "^9.0.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.6",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@types/pg": "^8.20.0",
"colorjs.io": "^0.6.1",
"fastify": "^5.4.0",
"pg": "^8.21.0"
"pg": "^8.21.0",
"prom-client": "^15.1.3",
"zod": "^4.4.3",
"zod-to-json-schema": "^3.25.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.0.0",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"pino-pretty": "^13.1.3",
"prettier": "^3.8.3",
"prisma": "^7.8.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0",
"typescript-eslint": "^8.59.4",
"vitest": "^4.1.6"
}
}

1203
backend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
-- Add cube extension for color matching
CREATE EXTENSION IF NOT EXISTS cube;
-- Create pantone_colors table
CREATE TABLE "pantone_colors" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"L" DOUBLE PRECISION NOT NULL,
"a" DOUBLE PRECISION NOT NULL,
"b" DOUBLE PRECISION NOT NULL,
CONSTRAINT "pantone_colors_pkey" PRIMARY KEY ("id")
);
-- Create unique index for pantone code
CREATE UNIQUE INDEX "pantone_colors_code_key" ON "pantone_colors"("code");

View File

@@ -174,3 +174,14 @@ model AiAuditLog {
@@index([createdAt])
@@map("ai_audit_logs")
}
model PantoneColor {
id String @id @default(uuid())
code String @unique
name String
L Float
a Float
b Float
@@map("pantone_colors")
}

View File

@@ -1,5 +1,12 @@
import { prisma } from '../src/lib/prisma.js'
import { IngredientCategory } from '../src/generated/prisma/enums.js'
import { randomBytes, scryptSync } from 'crypto'
function makeHash(password: string): string {
const salt = randomBytes(16)
const hash = scryptSync(password, salt, 64).toString('hex')
return `${salt.toString('hex')}:${hash}`
}
const ingredients = [
{ inciName: 'Glycerin', chineseName: '甘油', functionCategory: IngredientCategory.humectant, supplier: '丰益油脂', unitPrice: 15.00, description: '最常用的保湿剂,吸湿性强' },
@@ -18,34 +25,45 @@ const ingredients = [
{ 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: '调配香精' },
{ inciName: 'Sodium Benzoate', chineseName: '苯甲酸钠', functionCategory: IngredientCategory.preservative, supplier: '默克', unitPrice: 30.00, description: '酸性防腐剂,pH<5.5 有效' },
{ inciName: 'Tocopherol', chineseName: '生育酚维生素E', functionCategory: IngredientCategory.antioxidant, supplier: '巴斯夫', unitPrice: 250.00, description: '天然抗氧化剂,保护配方稳定性' },
{ inciName: 'BHT', chineseName: '丁羟甲苯', functionCategory: IngredientCategory.antioxidant, supplier: '默克', unitPrice: 45.00, description: '合成抗氧化剂,延长保质期' },
{ inciName: 'Fragrance', chineseName: '香精', functionCategory: IngredientCategory.fragrance, supplier: '奇华顿', unitPrice: 120.00, description: '化妆品用香精,供应商定制' },
{ inciName: 'Iron Oxide Red', chineseName: '氧化铁红', functionCategory: IngredientCategory.colorant, supplier: '默克', unitPrice: 85.00, description: '红色颜料,无机着色剂' },
{ inciName: 'Iron Oxide Yellow', chineseName: '氧化铁黄', functionCategory: IngredientCategory.colorant, supplier: '默克', unitPrice: 80.00, description: '黄色颜料,无机着色剂' },
{ inciName: 'Iron Oxide Black', chineseName: '氧化铁黑', functionCategory: IngredientCategory.colorant, supplier: '默克', unitPrice: 75.00, description: '黑色颜料,无机着色剂' },
{ inciName: 'Titanium Dioxide', chineseName: '二氧化钛', functionCategory: IngredientCategory.colorant, supplier: '巴斯夫', unitPrice: 120.00, description: '白色颜料,物理防晒剂' },
{ inciName: 'Triethanolamine', chineseName: '三乙醇胺', functionCategory: IngredientCategory.ph_adjuster, supplier: '陶氏化学', unitPrice: 25.00, description: 'pH调节剂常与卡波姆配合使用' },
{ inciName: 'Citric Acid', chineseName: '柠檬酸', functionCategory: IngredientCategory.ph_adjuster, supplier: '默克', unitPrice: 20.00, description: 'pH调节剂降低体系pH值' },
{ inciName: 'Avobenzone', chineseName: '阿伏苯宗', functionCategory: IngredientCategory.sunscreen, supplier: '巴斯夫', unitPrice: 350.00, description: 'UVA 化学防晒剂,广谱吸收' },
{ inciName: 'Octinoxate', chineseName: '桂皮酸盐', functionCategory: IngredientCategory.sunscreen, supplier: '巴斯夫', unitPrice: 180.00, description: 'UVB 化学防晒剂,最常用之一' },
{ inciName: 'Sodium Lauryl Sulfate', chineseName: '月桂醇硫酸酯钠', functionCategory: IngredientCategory.surfactant, supplier: '禾大', unitPrice: 20.00, description: '离子表面活性剂,强力清洁' },
{ inciName: 'Cocamidopropyl Betaine', chineseName: '椰油酰胺丙基甜菜碱', functionCategory: IngredientCategory.surfactant, supplier: '禾大', unitPrice: 35.00, description: '两性表面活性剂,温和清洁' },
{ inciName: 'Caprylic/Capric Triglyceride', chineseName: '辛酸/癸酸甘油三酯', functionCategory: IngredientCategory.emollient, supplier: '巴斯夫', unitPrice: 65.00, description: '中性油脂,铺展性好' },
{ inciName: 'Dimethicone', chineseName: '聚二甲基硅氧烷', functionCategory: IngredientCategory.emollient, supplier: '陶氏化学', unitPrice: 55.00, description: '硅油类润肤剂,赋予顺滑肤感' },
{ inciName: 'Squalane', chineseName: '角鲨烷', functionCategory: IngredientCategory.emollient, supplier: '阿莫科', unitPrice: 200.00, description: '天然润肤剂,亲肤性极佳' },
{ inciName: 'Water', chineseName: '去离子水', functionCategory: IngredientCategory.other, supplier: '自制', unitPrice: 0.10, description: '最常用溶剂,化妆品基础成分' },
{ inciName: 'Alcohol Denat.', chineseName: '变性乙醇', functionCategory: IngredientCategory.other, supplier: '默克', unitPrice: 30.00, description: '溶剂,收剑毛孔,需注意刺激性' },
{ inciName: 'Cyclopentasiloxane', chineseName: '环五聚二甲基硅氧烷', functionCategory: IngredientCategory.emollient, supplier: '陶氏化学', unitPrice: 80.00, description: '挥发性硅油,赋予丝滑肤感' },
{ inciName: 'Allantoin', chineseName: '尿囊素', functionCategory: IngredientCategory.other, supplier: '默克', unitPrice: 100.00, description: '舒缓抗炎成分,促进伤口愈合' },
{ inciName: 'Niacinamide', chineseName: '烟酰胺', functionCategory: IngredientCategory.humectant, supplier: 'DSM', unitPrice: 350.00, description: '维生素B3美白控油多功能活性物' },
{ inciName: 'Salicylic Acid', chineseName: '水杨酸', functionCategory: IngredientCategory.other, supplier: '默克', unitPrice: 60.00, description: '角质剥脱成分,改善粉刺' },
{ inciName: 'Urea', chineseName: '尿素', functionCategory: IngredientCategory.humectant, supplier: '默克', unitPrice: 15.00, description: '天然保湿因子NMF温和去角质' },
]
async function main() {
console.log('开始导入成分种子数据...')
const adminExists = await prisma.user.findUnique({ where: { username: 'admin' } })
if (adminExists) {
await prisma.user.update({
where: { username: 'admin' },
data: { passwordHash: makeHash('admin123') },
})
} else {
await prisma.user.create({
data: { username: 'admin', passwordHash: makeHash('admin123'), role: 'admin' },
})
}
console.log('管理员账号已就绪 (admin / admin123)')
await prisma.ingredient.createMany({
data: ingredients,
@@ -54,6 +72,65 @@ async function main() {
const count = await prisma.ingredient.count()
console.log(`成分种子数据导入完成!共 ${count} 条记录`)
const pantoneColors = [
{ code: '185 C', name: 'Vibrant Red', L: 48, a: 68, b: 48 },
{ code: '186 C', name: 'Strong Red', L: 44, a: 63, b: 42 },
{ code: '286 C', name: 'Strong Blue', L: 30, a: 12, b: -52 },
{ code: '287 C', name: 'Deep Blue', L: 27, a: 10, b: -48 },
{ code: '354 C', name: 'Bright Green', L: 55, a: -52, b: 28 },
{ code: '355 C', name: 'Deep Green', L: 50, a: -48, b: 24 },
{ code: '109 C', name: 'Golden Yellow', L: 82, a: 8, b: 95 },
{ code: '110 C', name: 'Deep Yellow', L: 76, a: 10, b: 85 },
{ code: '151 C', name: 'Orange', L: 65, a: 40, b: 70 },
{ code: '152 C', name: 'Deep Orange', L: 60, a: 35, b: 62 },
{ code: '205 C', name: 'Pink', L: 58, a: 42, b: -2 },
{ code: '206 C', name: 'Deep Pink', L: 50, a: 48, b: -4 },
{ code: 'Process Black C', name: 'Black', L: 18, a: 1, b: 0 },
{ code: 'Warm Red C', name: 'Warm Red', L: 48, a: 65, b: 42 },
{ code: 'Cool Gray 7 C', name: 'Cool Gray', L: 58, a: 0, b: -2 },
{ code: '7499 C', name: 'Pale Green', L: 72, a: -20, b: 22 },
{ code: '7416 C', name: 'Coral Pink', L: 55, a: 38, b: 18 },
{ code: '7417 C', name: 'Deep Coral', L: 50, a: 42, b: 22 },
{ code: '7421 C', name: 'Rose', L: 42, a: 50, b: -8 },
{ code: '7520 C', name: 'Peach', L: 70, a: 18, b: 28 },
{ code: '7548 C', name: 'Warm Beige', L: 72, a: 8, b: 15 },
{ code: '7610 C', name: 'Light Beige', L: 78, a: 4, b: 10 },
{ code: '7612 C', name: 'Sand', L: 65, a: 6, b: 14 },
{ code: '7614 C', name: 'Taupe', L: 55, a: 4, b: 8 },
{ code: '7621 C', name: 'Rose Gold', L: 62, a: 22, b: 10 },
]
await prisma.pantoneColor.createMany({
data: pantoneColors,
skipDuplicates: true,
})
console.log(`潘通色种子数据导入完成!共 ${pantoneColors.length} 条记录`)
const user = await prisma.user.findUnique({ where: { username: 'admin' } })
if (user) {
const demoIngs = await prisma.ingredient.findMany({ take: 4 })
const formulaExists = await prisma.formula.findFirst({ where: { name: '基础保湿精华(示例)' } })
if (!formulaExists && demoIngs.length >= 2) {
await prisma.$transaction(async (tx) => {
const f = await tx.formula.create({
data: { name: '基础保湿精华(示例)', description: '种子数据示例配方', createdBy: user.id, currentVersion: 1 },
})
const v = await tx.formulaVersion.create({
data: { formulaId: f.id, versionNumber: 1, description: '初始版本', snapshotData: {}, createdBy: user.id },
})
const phase = await tx.phase.create({ data: { name: '水相', formulaId: v.id, sortOrder: 0 } })
await tx.formulaIngredient.createMany({
data: [
{ formulaVersionId: v.id, phaseId: phase.id, ingredientId: demoIngs[0]!.id, percentage: 80 },
{ formulaVersionId: v.id, phaseId: phase.id, ingredientId: demoIngs[1]!.id, percentage: 15 },
{ formulaVersionId: v.id, phaseId: phase.id, ingredientId: demoIngs[2]!.id, percentage: 5 },
],
})
})
console.log('示例配方创建成功')
}
}
}
main()

View File

@@ -0,0 +1,7 @@
{
"AI_MOCK": "true",
"OPENAI_API_KEY": "",
"DEEPSEEK_API_KEY": "",
"OPENAI_BASE_URL": "",
"DEEPSEEK_BASE_URL": ""
}

View File

@@ -0,0 +1,23 @@
import { buildApp } from '../src/app.js'
import { writeFileSync, mkdirSync, existsSync } from 'fs'
import { join } from 'path'
async function main() {
const app = await buildApp({ skipAuth: true })
await app.ready()
const spec = app.swagger()
const outDir = join(import.meta.dirname, '..', 'generated')
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true })
writeFileSync(join(outDir, 'openapi.json'), JSON.stringify(spec, null, 2))
console.log(`OpenAPI spec written to generated/openapi.json (${JSON.stringify(spec).length} bytes)`)
await app.close()
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

View File

@@ -1,16 +1,41 @@
import Fastify from 'fastify'
import type { FastifyError } from 'fastify'
import type { FastifyError, FastifyRequest, FastifyReply } 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'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'
import swagger from '@fastify/swagger'
import swaggerUi from '@fastify/swagger-ui'
import { randomUUID } from 'crypto'
import { healthRoutes } from './modules/health/health.route.js'
import { ingredientRoutes } from './modules/ingredients/ingredients.route.js'
import { formulaRoutes } from './modules/formulas/formulas.route.js'
import { aiRoutes } from './modules/ai/ai.route.js'
import { colorRoutes } from './modules/color/color.route.js'
import { projectRoutes } from './modules/projects/projects.route.js'
import { authRoutes } from './modules/auth/auth.route.js'
import { verifyToken } from './modules/auth/auth.route.js'
import { configRoutes } from './modules/config/config.route.js'
import { prisma } from './lib/prisma.js'
import { initConfig } from './lib/configStore.js'
import { AppError } from './shared/errors/app-error.js'
import { appErrorsTotal } from './shared/metrics/metrics.js'
declare module 'fastify' {
interface FastifyRequest {
userId: string
}
}
interface BuildOptions {
skipAuth?: boolean
}
const BEARER_RE = /^Bearer (.+)$/
const PUBLIC_PREFIXES = ['/api/auth/', '/api/health', '/api/metrics']
export async function buildApp(options: BuildOptions = {}) {
initConfig()
export async function buildApp() {
const app = Fastify({
logger: {
transport: {
@@ -18,6 +43,7 @@ export async function buildApp() {
options: { colorize: true },
},
},
genReqId: () => randomUUID(),
})
await app.register(cors, {
@@ -25,23 +51,103 @@ export async function buildApp() {
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
await app.register(helmet, {
contentSecurityPolicy: false,
})
await app.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
})
await app.register(swagger, {
openapi: {
info: {
title: '配方研发智能平台 API',
version: '0.1.0',
description: 'AI 驱动的化妆品配方研发辅助工具',
},
servers: [{ url: 'http://localhost:3001' }],
},
})
await app.register(swaggerUi, {
routePrefix: '/docs',
})
app.setErrorHandler((error: FastifyError | Error, request, reply) => {
if (error instanceof AppError) {
appErrorsTotal.inc({ category: error.category, module: error.module ?? 'unknown', code: error.code })
request.log.warn({ err: error.toJSON(), requestId: request.id }, `app_error: ${error.code}`)
return reply.status(error.httpStatus).send({
error: error.message,
code: error.code,
statusCode: error.httpStatus,
})
}
const fastifyErr = error as FastifyError
const statusCode = fastifyErr.statusCode ?? 500
appErrorsTotal.inc({ category: 'internal', module: 'http', code: 'INTERNAL_ERROR' })
request.log.error({ err: error, requestId: request.id }, 'unhandled_error')
reply.status(statusCode).send({
error: statusCode >= 500 ? 'Internal Server Error' : error.message,
code: 'INTERNAL_ERROR',
statusCode,
})
})
app.decorateRequest('userId', '')
app.addHook('onRequest', async (request) => {
request.log = request.log.child({ requestId: request.id })
})
app.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => {
if (options.skipAuth) {
request.userId = 'system'
return
}
if (PUBLIC_PREFIXES.some(p => request.url.startsWith(p))) {
return
}
const authHeader = request.headers.authorization
if (!authHeader) {
return reply.status(401).send({ error: '缺少认证 Token', code: 'AUTH_TOKEN_MISSING', statusCode: 401 })
}
const match = authHeader.match(BEARER_RE)
if (!match || !match[1]) {
return reply.status(401).send({ error: 'Token 格式错误,应为 Bearer <token>', code: 'AUTH_TOKEN_INVALID', statusCode: 401 })
}
const payload = verifyToken(match[1])
if (!payload || !payload.userId) {
return reply.status(401).send({ error: 'Token 无效或已过期', code: 'AUTH_TOKEN_EXPIRED', statusCode: 401 })
}
const user = await prisma.user.findUnique({
where: { id: payload.userId as string },
select: { id: true },
})
if (!user) {
return reply.status(401).send({ error: '用户不存在', code: 'AUTH_USER_NOT_FOUND', statusCode: 401 })
}
request.userId = user.id
})
await app.register(healthRoutes, { prefix: '/api' })
await app.register(authRoutes, { prefix: '/api/auth' })
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,60 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
import { join } from 'path'
const CONFIG_DIR = join(process.cwd(), 'runtime')
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
interface RuntimeConfig {
AI_MOCK: string
OPENAI_API_KEY: string
DEEPSEEK_API_KEY: string
OPENAI_BASE_URL: string
DEEPSEEK_BASE_URL: string
}
function loadFromFile(): Partial<RuntimeConfig> {
if (!existsSync(CONFIG_FILE)) return {}
try {
const raw = readFileSync(CONFIG_FILE, 'utf-8')
return JSON.parse(raw) as Partial<RuntimeConfig>
} catch {
return {}
}
}
function saveToFile(config: Partial<RuntimeConfig>): void {
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true })
}
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8')
}
const config: RuntimeConfig = {
AI_MOCK: process.env['AI_MOCK'] ?? 'true',
OPENAI_API_KEY: process.env['OPENAI_API_KEY'] ?? '',
DEEPSEEK_API_KEY: process.env['DEEPSEEK_API_KEY'] ?? '',
OPENAI_BASE_URL: process.env['OPENAI_BASE_URL'] ?? '',
DEEPSEEK_BASE_URL: process.env['DEEPSEEK_BASE_URL'] ?? '',
...loadFromFile(),
}
export function getConfig(): Readonly<RuntimeConfig> {
return config
}
export function updateConfig(updates: Partial<RuntimeConfig>): void {
Object.assign(config, updates)
saveToFile(config)
}
export function getConfigStatus() {
return {
aiMock: config.AI_MOCK,
hasOpenAI: config.OPENAI_API_KEY !== '',
hasDeepseek: config.DEEPSEEK_API_KEY !== '',
}
}
export function initConfig(): void {
saveToFile(config)
}

View File

@@ -0,0 +1,24 @@
import { zodToJsonSchema } from 'zod-to-json-schema'
import type { z } from 'zod'
type AnyZod = z.ZodTypeAny
export function zSchema(zod: AnyZod): Record<string, unknown> {
return zodToJsonSchema(zod as never, { target: 'openApi3' }) as Record<string, unknown>
}
export function routeSchema(def: {
body?: AnyZod
query?: AnyZod
response?: AnyZod
summary?: string
tags?: string[]
}): Record<string, unknown> {
const schema: Record<string, unknown> = {}
if (def.summary) schema.summary = def.summary
if (def.tags) schema.tags = def.tags
if (def.body) schema.body = zSchema(def.body)
if (def.query) schema.querystring = zSchema(def.query)
if (def.response) schema.response = { 200: zSchema(def.response) }
return schema
}

View File

@@ -0,0 +1,27 @@
import type { FastifyReply } from 'fastify'
import { ZodError, type ZodSchema } from 'zod'
export function validate<T>(schema: ZodSchema<T>, data: unknown): { success: true; data: T } | { success: false; errors: string } {
try {
return { success: true, data: schema.parse(data) }
} catch (err) {
if (err instanceof ZodError) {
const messages = err.issues.map(e => `${e.path.join('.')}: ${e.message}`).join('; ')
return { success: false, errors: messages }
}
return { success: false, errors: '参数校验失败' }
}
}
export function validateOrReply<T>(schema: ZodSchema<T>, data: unknown, reply: FastifyReply): T | null {
if (data === undefined || data === null) {
reply.status(400).send({ error: '请求体不能为空' })
return null
}
const result = validate(schema, data)
if (!result.success) {
reply.status(400).send({ error: result.errors })
return null
}
return result.data
}

View File

@@ -1,12 +1,13 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import { prisma } from '../lib/prisma.js'
import { aiService } from '../services/ai/index.js'
import { prisma } from '../../lib/prisma.js'
import { aiService } from '../../services/ai/index.js'
import { exploreWithConstraints } from '../../services/formulaOptimizer.js'
import { predictFormulaSchema, exploreFormulaSchema, extractFormulaSchema } from './ai.schema.js'
import { validateOrReply } from '../../lib/validate.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: '成分列表不能为空' })
}
async function predictFormula(request: FastifyRequest, reply: FastifyReply) {
const body = validateOrReply(predictFormulaSchema, request.body, reply)
if (!body) return
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
@@ -15,7 +16,7 @@ async function predictFormula(request: FastifyRequest<{ Body: { ingredients: Arr
})
try {
const result = await aiService.predictMetrics(ingredients)
const result = await aiService.predictMetrics(body.ingredients)
reply.raw.write(`data: ${JSON.stringify({ type: 'result', content: result })}\n\n`)
} catch (err) {
reply.raw.write(`data: ${JSON.stringify({ type: 'error', content: (err as Error).message })}\n\n`)
@@ -23,14 +24,9 @@ async function predictFormula(request: FastifyRequest<{ Body: { ingredients: Arr
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: '至少设置一个约束条件' })
}
async function exploreFormula(request: FastifyRequest, reply: FastifyReply) {
const body = validateOrReply(exploreFormulaSchema, request.body, reply)
if (!body) return
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
@@ -38,37 +34,67 @@ async function exploreFormula(request: FastifyRequest<{ Body: {
Connection: 'keep-alive',
})
let sentOptions = 0
if (body.costLimit || body.baseFormulaId) {
try {
const localOptions = await exploreWithConstraints({
baseFormulaId: body.baseFormulaId,
costLimit: body.costLimit,
keepIngredients: body.keepIngredients,
excludeIngredients: body.excludeIngredients,
})
for (const option of localOptions) {
reply.raw.write(`data: ${JSON.stringify({ type: 'option', option })}\n\n`)
sentOptions++
}
} catch (err) {
request.log.warn({ err }, 'local optimizer failed')
}
}
try {
const result = await aiService.generateFormula(constraints)
const result = await aiService.generateFormula({
baseFormulaName: body.baseFormulaName,
baseIngredients: body.baseIngredients,
costLimit: body.costLimit,
keepIngredients: body.keepIngredients,
excludeIngredients: body.excludeIngredients,
targetMetrics: body.targetMetrics as Record<string, number> | undefined,
})
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`)
sentOptions++
}
} catch (err) {
reply.raw.write(`data: ${JSON.stringify({ type: 'error', content: (err as Error).message })}\n\n`)
}
if (sentOptions === 0) {
reply.raw.write(`data: ${JSON.stringify({ type: 'option', option: { name: '无可用方案', changes: [], predictedMetrics: {}, reasoning: '请调整约束条件后重试' } })}\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: '配方文本不能为空' })
}
async function extractFormula(request: FastifyRequest, reply: FastifyReply) {
const body = validateOrReply(extractFormulaSchema, request.body, reply)
if (!body) return
try {
const result = await aiService.extractFormula(text)
const result = await aiService.extractFormula(body.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 提取失败,请重试' })
return reply.status(500).send({ error: 'AI 提取失败,请重试', code: 'AI_EXTRACTION_FAILED', statusCode: 500 })
}
}
async function nlSearch(request: FastifyRequest<{ Querystring: { q: string } }>, reply: FastifyReply) {
const q = request.query.q?.trim()
async function nlSearch(request: FastifyRequest, reply: FastifyReply) {
const q = (request.query as Record<string, string>)['q']?.trim()
if (!q) return reply.status(400).send({ error: '搜索词不能为空' })
try {

View File

@@ -0,0 +1,23 @@
import { z } from 'zod'
export const predictFormulaSchema = z.object({
ingredients: z.array(z.object({
name: z.string(),
percentage: z.number().gt(0),
category: z.string(),
})).min(1, '成分列表不能为空'),
})
export const exploreFormulaSchema = z.object({
baseFormulaId: z.string().optional(),
baseFormulaName: z.string().optional(),
baseIngredients: z.array(z.object({ name: z.string(), percentage: z.number() })).optional(),
costLimit: z.number().gt(0).optional(),
keepIngredients: z.array(z.string()).optional(),
excludeIngredients: z.array(z.string()).optional(),
targetMetrics: z.record(z.string(), z.number()).optional(),
})
export const extractFormulaSchema = z.object({
text: z.string().min(1, '配方文本不能为空'),
})

View File

@@ -0,0 +1,91 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import { randomBytes, scrypt, timingSafeEqual, createHmac } from 'crypto'
import { promisify } from 'util'
import { prisma } from '../../lib/prisma.js'
import { registerSchema, loginSchema } from './auth.schema.js'
import { validateOrReply } from '../../lib/validate.js'
import { routeSchema } from '../../lib/swagger.js'
const scryptAsync = promisify(scrypt)
export const JWT_SECRET = process.env['JWT_SECRET'] ?? 'dev-secret-change-me'
async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16)
const hash = await scryptAsync(password, salt, 64) as Buffer
return `${salt.toString('hex')}:${hash.toString('hex')}`
}
async function verifyPassword(password: string, stored: string): Promise<boolean> {
const [salt, hash] = stored.split(':')
if (!salt || !hash) return false
const computed = await scryptAsync(password, Buffer.from(salt, 'hex'), 64) as Buffer
return timingSafeEqual(Buffer.from(hash, 'hex'), 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 = createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url')
return `${header}.${body}.${sig}`
}
export function verifyToken(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const expected = createHmac('sha256', JWT_SECRET).update(`${parts[0]}.${parts[1]}`).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, reply: FastifyReply) {
const body = validateOrReply(registerSchema, request.body, reply)
if (!body) return
const { username, password } = body
const existing = await prisma.user.findUnique({ where: { username } })
if (existing) return reply.status(409).send({ error: '用户名已存在', code: 'AUTH_USERNAME_EXISTS', statusCode: 409 })
const user = await prisma.user.create({ data: { username, passwordHash: await 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, reply: FastifyReply) {
const body = validateOrReply(loginSchema, request.body, reply)
if (!body) return
const { username, password } = body
const user = await prisma.user.findUnique({ where: { username } })
if (!user || !(await verifyPassword(password, user.passwordHash))) {
return reply.status(401).send({ error: '用户名或密码错误', code: 'AUTH_INVALID_CREDENTIALS', statusCode: 401 })
}
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: '未认证', code: 'AUTH_TOKEN_MISSING', statusCode: 401 })
const payload = verifyToken(auth.slice(7))
if (!payload) return reply.status(401).send({ error: 'Token 无效', code: 'AUTH_TOKEN_INVALID', statusCode: 401 })
const user = await prisma.user.findUnique({ where: { id: payload.userId as string } })
if (!user) return reply.status(401).send({ error: '用户不存在', code: 'AUTH_USER_NOT_FOUND', statusCode: 401 })
return reply.send({ data: { id: user.id, username: user.username, role: user.role } })
}
export async function authRoutes(app: FastifyInstance) {
app.post('/register', {
schema: routeSchema({ body: registerSchema, summary: '注册新用户', tags: ['auth'] }),
}, register)
app.post('/login', {
schema: routeSchema({ body: loginSchema, summary: '用户登录', tags: ['auth'] }),
}, login)
app.get('/me', {
schema: routeSchema({ summary: '获取当前用户信息', tags: ['auth'] }),
}, me)
}

View File

@@ -0,0 +1,11 @@
import { z } from 'zod'
export const registerSchema = z.object({
username: z.string().min(1, '用户名不能为空'),
password: z.string().min(4, '密码至少4位'),
})
export const loginSchema = z.object({
username: z.string().min(1, '用户名不能为空'),
password: z.string().min(1, '密码不能为空'),
})

View File

@@ -0,0 +1,158 @@
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'
import {
recommendColorSchema, matchColorQuerySchema, saveColorFormulaSchema, pantoneQuerySchema,
} from './color.schema.js'
import { validateOrReply } from '../../lib/validate.js'
import { routeSchema } from '../../lib/swagger.js'
import Color from 'colorjs.io'
interface LabInput {
L: number; a: number; b: number
}
function deltaE2000(lab1: LabInput, lab2: LabInput): number {
const c1 = new Color('lab', [lab1.L, lab1.a, lab1.b])
const c2 = new Color('lab', [lab2.L, lab2.a, lab2.b])
return c1.deltaE(c2, '2000')
}
async function recommend(request: FastifyRequest, reply: FastifyReply) {
const body = validateOrReply(recommendColorSchema, request.body, reply)
if (!body) return
const { targetLab } = body
const candidates = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT id FROM color_formulas
ORDER BY cube_distance(
cube(ARRAY[COALESCE(target_lab->>'L','0')::float, COALESCE(target_lab->>'a','0')::float, COALESCE(target_lab->>'b','0')::float]),
cube(ARRAY[${targetLab.L}, ${targetLab.a}, ${targetLab.b}])
)
LIMIT 50
`
const candidateIds = candidates.map(c => c.id)
const matched = candidateIds.length > 0
? await prisma.colorFormula.findMany({
where: { id: { in: candidateIds } },
select: { id: true, name: true, targetLab: true, actualLab: true, deltaE: true },
})
: []
const ordered = matched
.map(f => {
const tl = f.targetLab as unknown as LabInput
return { ...f, distance: deltaE2000(targetLab, tl) }
})
.sort((a, b) => a.distance - b.distance)
.slice(0, 5)
const colorants = await prisma.ingredient.findMany({
where: { functionCategory: 'colorant' },
select: { inciName: true, chineseName: true },
})
const aiResult = await aiService.recommendColorants(
targetLab,
colorants.map(c => c.inciName),
)
let recommendations: Array<Record<string, unknown>> = []
try {
const parsed = JSON.parse(aiResult) as { recommendations?: Array<Record<string, unknown>> }
recommendations = parsed.recommendations ?? []
} catch {
void 0
}
return reply.send({
recommendations,
matchedFormulas: ordered.map(m => ({ id: m.id, name: m.name, deltaE: m.deltaE ?? m.distance })),
})
}
async function matchFormulas(request: FastifyRequest, reply: FastifyReply) {
const query = validateOrReply(matchColorQuerySchema, request.query, reply)
if (!query) return
const { L, a, b, limit } = query
const candidates = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT id FROM color_formulas
ORDER BY cube_distance(
cube(ARRAY[COALESCE(target_lab->>'L','0')::float, COALESCE(target_lab->>'a','0')::float, COALESCE(target_lab->>'b','0')::float]),
cube(ARRAY[${L}, ${a}, ${b}])
)
LIMIT ${limit * 2}
`
const candidateIds = candidates.map(c => c.id)
const matched = candidateIds.length > 0
? await prisma.colorFormula.findMany({
where: { id: { in: candidateIds } },
select: { id: true, name: true, targetLab: true, deltaE: true, colorantComposition: true },
})
: []
const target: LabInput = { L, a, b }
const ordered = matched
.map(f => ({ ...f, distance: deltaE2000(target, f.targetLab as unknown as LabInput) }))
.sort((a, b) => a.distance - b.distance)
.slice(0, limit)
return reply.send({ data: ordered })
}
async function saveColorFormula(request: FastifyRequest, reply: FastifyReply) {
const body = validateOrReply(saveColorFormulaSchema, request.body, reply)
if (!body) return
const formula = await prisma.colorFormula.create({
data: {
name: body.name ?? '未命名颜色配方',
targetLab: body.targetLab as unknown as Prisma.InputJsonValue,
actualLab: body.actualLab as unknown as Prisma.InputJsonValue ?? null,
deltaE: body.deltaE ?? null,
colorantComposition: body.colorantComposition as unknown as Prisma.InputJsonValue ?? null,
formulaId: body.formulaId ?? null,
createdBy: request.userId,
},
})
return reply.status(201).send({ data: formula })
}
async function listPantone(request: FastifyRequest, reply: FastifyReply) {
const query = validateOrReply(pantoneQuerySchema, request.query, reply)
if (!query) return
const { search, page, limit } = query
const where: Record<string, unknown> = {}
if (search && search.length >= 1) {
where.OR = [
{ code: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
]
}
const [data, total] = await Promise.all([
prisma.pantoneColor.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: { code: 'asc' },
}),
prisma.pantoneColor.count({ where }),
])
return reply.send({
data,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
})
}
export async function colorRoutes(app: FastifyInstance) {
app.post('/recommend', { schema: routeSchema({ body: recommendColorSchema, summary: 'AI 配色推荐', tags: ['color'] }) }, recommend)
app.get('/formulas/match', { schema: routeSchema({ summary: '颜色配方匹配', tags: ['color'] }) }, matchFormulas)
app.post('/formulas', { schema: routeSchema({ body: saveColorFormulaSchema, summary: '保存颜色配方', tags: ['color'] }) }, saveColorFormula)
app.get('/pantone', { schema: routeSchema({ summary: '潘通色搜索', tags: ['color'] }) }, listPantone)
}

View File

@@ -0,0 +1,33 @@
import { z } from 'zod'
export const labColorSchema = z.object({
L: z.number().min(0).max(100),
a: z.number(),
b: z.number(),
})
export const recommendColorSchema = z.object({
targetLab: labColorSchema,
})
export const matchColorQuerySchema = z.object({
L: z.coerce.number().min(0).max(100),
a: z.coerce.number(),
b: z.coerce.number(),
limit: z.coerce.number().int().min(1).max(20).default(5),
})
export const saveColorFormulaSchema = z.object({
name: z.string().optional(),
targetLab: labColorSchema,
actualLab: labColorSchema.optional(),
deltaE: z.number().gte(0).optional(),
colorantComposition: z.unknown().optional(),
formulaId: z.string().optional(),
})
export const pantoneQuerySchema = z.object({
search: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
})

View File

@@ -0,0 +1,44 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import { getConfigStatus, updateConfig } from '../../lib/configStore.js'
import { aiService } from '../../services/ai/index.js'
import { updateConfigSchema, testApiSchema } from './config.schema.js'
import { validateOrReply } from '../../lib/validate.js'
import { requireRole } from '../../shared/middleware/rbac.js'
async function getConfig(_request: FastifyRequest, reply: FastifyReply) {
return reply.send(getConfigStatus())
}
async function updateConfigHandler(request: FastifyRequest, reply: FastifyReply) {
const body = validateOrReply(updateConfigSchema, request.body, reply)
if (!body) return
const updates: Record<string, string> = {}
if (body.aiMock !== undefined) updates.AI_MOCK = body.aiMock
if (body.openaiKey) updates.OPENAI_API_KEY = body.openaiKey
if (body.deepseekKey) updates.DEEPSEEK_API_KEY = body.deepseekKey
if (body.openaiBaseUrl !== undefined) updates.OPENAI_BASE_URL = body.openaiBaseUrl
if (body.deepseekBaseUrl !== undefined) updates.DEEPSEEK_BASE_URL = body.deepseekBaseUrl
updateConfig(updates)
aiService.reload(updates)
return reply.send({ ok: true })
}
async function testApi(request: FastifyRequest, reply: FastifyReply) {
const body = validateOrReply(testApiSchema, request.body, reply)
if (!body) return
try {
const result = await aiService.testConnection(body.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('/', { preHandler: [requireRole('admin')] }, getConfig)
app.put('/', { preHandler: [requireRole('admin')] }, updateConfigHandler)
app.post('/test', { preHandler: [requireRole('admin')] }, testApi)
}

View File

@@ -0,0 +1,13 @@
import { z } from 'zod'
export const updateConfigSchema = z.object({
openaiKey: z.string().optional(),
deepseekKey: z.string().optional(),
openaiBaseUrl: z.string().optional(),
deepseekBaseUrl: z.string().optional(),
aiMock: z.string().optional(),
})
export const testApiSchema = z.object({
provider: z.string().min(1),
})

View File

@@ -0,0 +1,189 @@
import { prisma } from '../../lib/prisma.js'
import type { Prisma as PrismaNS } from '../../generated/prisma/client.js'
import type { CreateFormulaInput, FormulaQueryInput } from './formulas.schema.js'
interface PhaseInput {
name: string
sortOrder?: number
ingredients: { ingredientId: string; percentage: number; processNotes?: string }[]
}
export const formulaRepository = {
async list(query: FormulaQueryInput) {
const { projectId, search, page, limit, sortBy = 'updatedAt', sortOrder = 'desc' } = query
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: (page - 1) * limit,
take: limit,
orderBy: { [sortBy]: sortOrder },
include: { project: { select: { id: true, name: true } } },
}),
prisma.formula.count({ where }),
])
return { data, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) } }
},
getById(id: string) {
return prisma.formula.findUnique({
where: { id },
include: {
versions: {
orderBy: { versionNumber: 'desc' },
take: 1,
include: {
phases: {
orderBy: { sortOrder: 'asc' },
include: { ingredients: { include: { ingredient: true } } },
},
},
},
project: { select: { id: true, name: true } },
},
})
},
async create(input: CreateFormulaInput, createdBy: string) {
const { name, description, projectId, phases } = input
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 PrismaNS.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
})
return formula.id
},
updateMeta(id: string, data: { name?: string; description?: string }) {
return prisma.formula.update({ where: { id }, data })
},
async updateComposition(id: string, phases: PhaseInput[], createdBy: string) {
const existing = await prisma.formula.findUnique({ where: { id } })
if (!existing) return null
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 PrismaNS.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,
},
})
}
}
await tx.formula.update({
where: { id: existing.id },
data: { currentVersion: newVersionNumber },
})
return tx.formula.findUnique({
where: { id: existing.id },
include: {
versions: {
orderBy: { versionNumber: 'desc' },
take: 1,
include: {
phases: { include: { ingredients: { include: { ingredient: true } } } },
},
},
},
})
})
return formula
},
async delete(id: string) {
return prisma.$transaction(async (tx) => {
const versions = await tx.formulaVersion.findMany({
where: { formulaId: id },
select: { id: true },
})
for (const v of versions) {
await tx.phase.deleteMany({ where: { formulaId: v.id } })
await tx.formulaIngredient.deleteMany({ where: { formulaVersionId: v.id } })
}
await tx.formulaVersion.deleteMany({ where: { formulaId: id } })
await tx.formula.delete({ where: { id } })
})
},
}

View File

@@ -0,0 +1,91 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import { formulaService } from './formulas.service.js'
import { createFormulaSchema, updateCompositionSchema, formulaQuerySchema } from './formulas.schema.js'
import { validateOrReply } from '../../lib/validate.js'
import { routeSchema } from '../../lib/swagger.js'
import { requireFormulaOwnership } from '../../shared/middleware/ownership.js'
import type { CreateFormulaInput, UpdateCompositionInput, FormulaQueryInput } from './formulas.schema.js'
async function createFormula(request: FastifyRequest, reply: FastifyReply) {
const body = validateOrReply(createFormulaSchema, request.body, reply)
if (!body) return
const input = body as CreateFormulaInput
const percentError = formulaService.validate(input.phases)
if (percentError) {
return reply.status(400).send({ error: percentError, code: 'FORMULA_PERCENTAGE_TOTAL_INVALID', statusCode: 400 })
}
const result = await formulaService.create(input, request.userId)
return reply.status(201).send({ data: result })
}
async function getFormula(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
const formula = await formulaService.getById(request.params.id)
if (!formula) return reply.status(404).send({ error: '配方不存在', code: 'FORMULA_NOT_FOUND', statusCode: 404 })
return reply.send({ data: formula })
}
async function listFormulas(request: FastifyRequest, reply: FastifyReply) {
const query = validateOrReply(formulaQuerySchema, request.query, reply)
if (!query) return
const result = await formulaService.list(query as FormulaQueryInput)
return reply.send(result)
}
async function updateFormula(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const existing = await formulaService.getById(request.params.id)
if (!existing) return reply.status(404).send({ error: '配方不存在', code: 'FORMULA_NOT_FOUND', statusCode: 404 })
const { name, description } = request.body as { name?: string; description?: string }
const formula = await formulaService.updateMeta(request.params.id, { name, description })
return reply.send({ data: formula })
}
async function deleteFormula(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const existing = await formulaService.getById(request.params.id)
if (!existing) return reply.status(404).send({ error: '配方不存在', code: 'FORMULA_NOT_FOUND', statusCode: 404 })
await formulaService.delete(request.params.id, request.userId)
return reply.status(204).send()
}
async function updateComposition(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
const body = validateOrReply(updateCompositionSchema, request.body, reply)
if (!body) return
const { phases } = body as UpdateCompositionInput
const percentError = formulaService.validate(phases)
if (percentError) {
return reply.status(400).send({ error: percentError, code: 'FORMULA_PERCENTAGE_TOTAL_INVALID', statusCode: 400 })
}
const formula = await formulaService.updateComposition(request.params.id, phases, request.userId)
if (!formula) return reply.status(404).send({ error: '配方不存在', code: 'FORMULA_NOT_FOUND', statusCode: 404 })
return reply.send({ data: formula })
}
export async function formulaRoutes(app: FastifyInstance) {
app.get('/', { schema: routeSchema({ query: formulaQuerySchema, summary: '配方列表', tags: ['formulas'] }) }, listFormulas)
app.get('/:id', { schema: routeSchema({ summary: '配方详情', tags: ['formulas'] }) }, getFormula)
app.post('/', { schema: routeSchema({ body: createFormulaSchema, summary: '创建配方', tags: ['formulas'] }) }, createFormula)
app.put('/:id', {
schema: routeSchema({ summary: '更新配方元信息', tags: ['formulas'] }),
preHandler: [requireFormulaOwnership()],
}, updateFormula)
app.delete('/:id', {
schema: routeSchema({ summary: '删除配方', tags: ['formulas'] }),
preHandler: [requireFormulaOwnership()],
}, deleteFormula)
app.put('/:id/composition', {
schema: routeSchema({ body: updateCompositionSchema, summary: '更新配方成分', tags: ['formulas'] }),
preHandler: [requireFormulaOwnership()],
}, updateComposition)
}

View File

@@ -0,0 +1,37 @@
import { z } from 'zod'
export const phaseIngredientSchema = z.object({
ingredientId: z.string().min(1),
percentage: z.number().gt(0).lte(100),
processNotes: z.string().optional(),
})
export const phaseInputSchema = z.object({
name: z.string().min(1, '相名称不能为空'),
sortOrder: z.number().int().gte(0).optional(),
ingredients: z.array(phaseIngredientSchema).min(1, '相至少需要一个成分'),
})
export const createFormulaSchema = z.object({
name: z.string().min(1, '配方名称不能为空'),
description: z.string().optional(),
projectId: z.string().optional(),
phases: z.array(phaseInputSchema).min(1, '至少需要一个相'),
})
export const updateCompositionSchema = z.object({
phases: z.array(phaseInputSchema).min(1, '至少需要一个相'),
})
export const formulaQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().optional(),
projectId: z.string().optional(),
sortBy: z.enum(['createdAt', 'updatedAt', 'name']).optional(),
sortOrder: z.enum(['asc', 'desc']).optional(),
})
export type CreateFormulaInput = z.infer<typeof createFormulaSchema>
export type UpdateCompositionInput = z.infer<typeof updateCompositionSchema>
export type FormulaQueryInput = z.infer<typeof formulaQuerySchema>

View File

@@ -0,0 +1,67 @@
import { formulaRepository } from './formulas.repository.js'
import type { CreateFormulaInput, FormulaQueryInput } from './formulas.schema.js'
import { auditService } from '../../shared/audit/audit.service.js'
interface PhaseInput {
name: string
sortOrder?: number
ingredients: { ingredientId: string; percentage: number; processNotes?: string }[]
}
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
}
export const formulaService = {
validate(phases: PhaseInput[]) {
return validatePercentages(phases)
},
list(query: FormulaQueryInput) {
return formulaRepository.list(query)
},
getById(id: string) {
return formulaRepository.getById(id)
},
async create(input: CreateFormulaInput, createdBy: string) {
const id = await formulaRepository.create(input, createdBy)
auditService.log({ action: 'create', resource: 'formula', resourceId: id, userId: createdBy })
return formulaRepository.getById(id)
},
updateMeta(id: string, data: { name?: string; description?: string }) {
return formulaRepository.updateMeta(id, data)
},
async updateComposition(id: string, phases: PhaseInput[], createdBy: string) {
const result = await formulaRepository.updateComposition(id, phases, createdBy)
if (result) {
auditService.log({
action: 'update', resource: 'formula', resourceId: id, userId: createdBy,
diff: { phases: phases.map(p => p.name) },
})
}
return result
},
async delete(id: string, userId: string) {
await formulaRepository.delete(id)
auditService.log({ action: 'delete', resource: 'formula', resourceId: id, userId })
},
}

View File

@@ -1,11 +1,11 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import { buildApp } from '../../app.js'
import type { FastifyInstance } from 'fastify'
let app: FastifyInstance
beforeAll(async () => {
app = await buildApp()
app = await buildApp({ skipAuth: true })
await app.ready()
await app.inject({
@@ -13,7 +13,7 @@ beforeAll(async () => {
payload: { inciName: '__system__', chineseName: '__system__', functionCategory: 'other' },
})
const { prisma } = await import('../lib/prisma.js')
const { prisma } = await import('../../lib/prisma.js')
await prisma.user.upsert({
where: { username: 'system' },
update: {},

View File

@@ -0,0 +1,36 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import { prisma } from '../../lib/prisma.js'
import { registry } from '../../shared/metrics/metrics.js'
let isShuttingDown = false
export function setShuttingDown() {
isShuttingDown = true
}
export async function healthRoutes(app: FastifyInstance) {
app.get('/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() }
})
app.get('/health/live', async (_req: FastifyRequest, reply: FastifyReply) => {
try {
await prisma.$queryRaw`SELECT 1`
return reply.status(200).send({ status: 'ok' })
} catch {
return reply.status(503).send({ status: 'error', reason: 'database_unreachable' })
}
})
app.get('/health/ready', async (_req: FastifyRequest, reply: FastifyReply) => {
if (isShuttingDown) {
return reply.status(503).send({ status: 'shutting_down' })
}
return reply.status(200).send({ status: 'ready' })
})
app.get('/metrics', async (_req: FastifyRequest, reply: FastifyReply) => {
reply.header('Content-Type', registry.contentType)
return reply.send(await registry.metrics())
})
}

View File

@@ -0,0 +1,64 @@
import { prisma } from '../../lib/prisma.js'
import type { CreateIngredientInput, UpdateIngredientInput, IngredientQueryInput } from './ingredients.schema.js'
export const ingredientRepository = {
async list(query: IngredientQueryInput) {
const { search, category, page, limit } = query
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: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.ingredient.count({ where }),
])
return {
data,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
}
},
getById(id: string) {
return prisma.ingredient.findUnique({ where: { id } })
},
create(input: CreateIngredientInput) {
return prisma.ingredient.create({
data: {
inciName: input.inciName,
chineseName: input.chineseName,
functionCategory: input.functionCategory,
supplier: input.supplier,
unit: input.unit ?? 'kg',
unitPrice: input.unitPrice,
description: input.description,
},
})
},
update(id: string, data: UpdateIngredientInput) {
return prisma.ingredient.update({ where: { id }, data })
},
async getUsageCount(id: string) {
return prisma.formulaIngredient.count({ where: { ingredientId: id } })
},
delete(id: string) {
return prisma.ingredient.delete({ where: { id } })
},
}

View File

@@ -0,0 +1,77 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import { ingredientService } from './ingredients.service.js'
import { createIngredientSchema, updateIngredientSchema, ingredientQuerySchema } from './ingredients.schema.js'
import { validateOrReply } from '../../lib/validate.js'
import { routeSchema } from '../../lib/swagger.js'
import type { CreateIngredientInput, UpdateIngredientInput, IngredientQueryInput } from './ingredients.schema.js'
async function getIngredients(request: FastifyRequest, reply: FastifyReply) {
const query = validateOrReply(ingredientQuerySchema, request.query, reply)
if (!query) return
const result = await ingredientService.list(query as IngredientQueryInput)
return reply.send(result)
}
async function getIngredient(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const ingredient = await ingredientService.getById(request.params.id)
if (!ingredient) {
return reply.status(404).send({ error: '成分不存在', code: 'INGREDIENT_NOT_FOUND', statusCode: 404 })
}
return reply.send({ data: ingredient })
}
async function createIngredient(request: FastifyRequest, reply: FastifyReply) {
const body = validateOrReply(createIngredientSchema, request.body, reply)
if (!body) return
const ingredient = await ingredientService.create(body as CreateIngredientInput, request.userId)
return reply.status(201).send({ data: ingredient })
}
async function updateIngredient(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const existing = await ingredientService.getById(request.params.id)
if (!existing) {
return reply.status(404).send({ error: '成分不存在', code: 'INGREDIENT_NOT_FOUND', statusCode: 404 })
}
const body = validateOrReply(updateIngredientSchema, request.body, reply)
if (!body) return
const ingredient = await ingredientService.update(request.params.id, body as UpdateIngredientInput, request.userId)
return reply.send({ data: ingredient })
}
async function deleteIngredient(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const existing = await ingredientService.getById(request.params.id)
if (!existing) {
return reply.status(404).send({ error: '成分不存在', code: 'INGREDIENT_NOT_FOUND', statusCode: 404 })
}
const result = await ingredientService.delete(request.params.id, request.userId)
if (!result.deleted) {
return reply.status(409).send({
error: '该成分已被配方引用,无法删除',
code: 'INGREDIENT_IN_USE',
usageCount: result.usageCount,
statusCode: 409,
})
}
return reply.status(204).send()
}
export async function ingredientRoutes(app: FastifyInstance) {
app.get('/', { schema: routeSchema({ query: ingredientQuerySchema, summary: '成分列表', tags: ['ingredients'] }) }, getIngredients)
app.get('/:id', { schema: routeSchema({ summary: '成分详情', tags: ['ingredients'] }) }, getIngredient)
app.post('/', { schema: routeSchema({ body: createIngredientSchema, summary: '创建成分', tags: ['ingredients'] }) }, createIngredient)
app.put('/:id', { schema: routeSchema({ body: updateIngredientSchema, summary: '更新成分', tags: ['ingredients'] }) }, updateIngredient)
app.delete('/:id', { schema: routeSchema({ summary: '删除成分', tags: ['ingredients'] }) }, deleteIngredient)
}

View File

@@ -0,0 +1,30 @@
import { z } from 'zod'
export const ingredientCategorySchema = z.enum([
'emulsifier', 'humectant', 'thickener', 'preservative', 'antioxidant',
'fragrance', 'colorant', 'ph_adjuster', 'sunscreen', 'surfactant',
'emollient', 'other',
])
export const createIngredientSchema = z.object({
inciName: z.string().min(1, 'INCI名称不能为空'),
chineseName: z.string().min(1, '中文名不能为空'),
functionCategory: ingredientCategorySchema,
supplier: z.string().optional(),
unit: z.string().optional(),
unitPrice: z.number().gte(0).optional(),
description: z.string().optional(),
})
export const updateIngredientSchema = createIngredientSchema.partial()
export const ingredientQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().optional(),
category: ingredientCategorySchema.optional(),
})
export type CreateIngredientInput = z.infer<typeof createIngredientSchema>
export type UpdateIngredientInput = z.infer<typeof updateIngredientSchema>
export type IngredientQueryInput = z.infer<typeof ingredientQuerySchema>

View File

@@ -0,0 +1,35 @@
import { ingredientRepository } from './ingredients.repository.js'
import type { CreateIngredientInput, UpdateIngredientInput, IngredientQueryInput } from './ingredients.schema.js'
import { auditService } from '../../shared/audit/audit.service.js'
export const ingredientService = {
list(query: IngredientQueryInput) {
return ingredientRepository.list(query)
},
getById(id: string) {
return ingredientRepository.getById(id)
},
async create(input: CreateIngredientInput, userId: string) {
const ingredient = await ingredientRepository.create(input)
auditService.log({ action: 'create', resource: 'ingredient', resourceId: ingredient.id, userId })
return ingredient
},
async update(id: string, data: UpdateIngredientInput, userId: string) {
const ingredient = await ingredientRepository.update(id, data)
auditService.log({ action: 'update', resource: 'ingredient', resourceId: id, userId, diff: data })
return ingredient
},
async delete(id: string, userId: string) {
const usageCount = await ingredientRepository.getUsageCount(id)
if (usageCount > 0) {
return { deleted: false, usageCount }
}
await ingredientRepository.delete(id)
auditService.log({ action: 'delete', resource: 'ingredient', resourceId: id, userId })
return { deleted: true, usageCount: 0 }
},
}

View File

@@ -1,11 +1,11 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import { buildApp } from '../../app.js'
import type { FastifyInstance } from 'fastify'
let app: FastifyInstance
beforeAll(async () => {
app = await buildApp()
app = await buildApp({ skipAuth: true })
await app.ready()
})

View File

@@ -0,0 +1,24 @@
import { prisma } from '../../lib/prisma.js'
export const projectRepository = {
list() {
return prisma.project.findMany({
orderBy: { createdAt: 'desc' },
include: { _count: { select: { formulas: true } } },
})
},
create(name: string, description: string | undefined, createdBy: string) {
return prisma.project.create({
data: { name, description: description ?? null, createdBy },
})
},
update(id: string, data: { name?: string; description?: string }) {
return prisma.project.update({ where: { id }, data })
},
delete(id: string) {
return prisma.project.delete({ where: { id } })
},
}

View File

@@ -0,0 +1,39 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import { projectService } from './projects.service.js'
import { createProjectSchema, updateProjectSchema } from './projects.schema.js'
import { validateOrReply } from '../../lib/validate.js'
import { routeSchema } from '../../lib/swagger.js'
async function listProjects(_request: FastifyRequest, reply: FastifyReply) {
const projects = await projectService.list()
return reply.send({ data: projects })
}
async function createProject(request: FastifyRequest, reply: FastifyReply) {
const body = validateOrReply(createProjectSchema, request.body, reply)
if (!body) return
const project = await projectService.create(body, request.userId)
return reply.status(201).send({ data: project })
}
async function updateProject(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const body = validateOrReply(updateProjectSchema, request.body, reply)
if (!body) return
const project = await projectService.update(request.params.id, body)
return reply.send({ data: project })
}
async function deleteProject(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
await projectService.delete(request.params.id, request.userId)
return reply.status(204).send()
}
export async function projectRoutes(app: FastifyInstance) {
app.get('/', { schema: routeSchema({ summary: '项目列表', tags: ['projects'] }) }, listProjects)
app.post('/', { schema: routeSchema({ body: createProjectSchema, summary: '创建项目', tags: ['projects'] }) }, createProject)
app.put('/:id', { schema: routeSchema({ body: updateProjectSchema, summary: '更新项目', tags: ['projects'] }) }, updateProject)
app.delete('/:id', { schema: routeSchema({ summary: '删除项目', tags: ['projects'] }) }, deleteProject)
}

View File

@@ -0,0 +1,14 @@
import { z } from 'zod'
export const createProjectSchema = z.object({
name: z.string().min(1, '项目名称不能为空'),
description: z.string().optional(),
})
export const updateProjectSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional(),
})
export type CreateProjectInput = z.infer<typeof createProjectSchema>
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>

View File

@@ -0,0 +1,25 @@
import { projectRepository } from './projects.repository.js'
import type { CreateProjectInput } from './projects.schema.js'
import { auditService } from '../../shared/audit/audit.service.js'
export const projectService = {
async list() {
return projectRepository.list()
},
async create(input: CreateProjectInput, createdBy: string) {
const project = await projectRepository.create(input.name, input.description, createdBy)
auditService.log({ action: 'create', resource: 'project', resourceId: project.id, userId: createdBy })
return project
},
async update(id: string, data: { name?: string; description?: string }) {
const project = await projectRepository.update(id, data)
return project
},
async delete(id: string, userId: string) {
await projectRepository.delete(id)
auditService.log({ action: 'delete', resource: 'project', resourceId: id, userId })
},
}

View File

@@ -1,76 +0,0 @@
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

@@ -1,91 +0,0 @@
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

@@ -1,47 +0,0 @@
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

@@ -1,295 +0,0 @@
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

@@ -1,7 +0,0 @@
import type { FastifyInstance } from 'fastify'
export async function healthRoutes(app: FastifyInstance) {
app.get('/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() }
})
}

View File

@@ -1,186 +0,0 @@
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

@@ -1,39 +0,0 @@
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)
}

View File

@@ -1,9 +1,27 @@
import { buildApp } from './app.js'
import { setShuttingDown } from './modules/health/health.route.js'
async function start() {
const app = await buildApp()
const port = Number(process.env.PORT) || 3001
const shutdown = async (signal: string) => {
app.log.info({ signal }, 'shutdown initiated')
setShuttingDown()
try {
await app.close()
app.log.info('shutdown complete')
process.exit(0)
} catch (err) {
app.log.error(err, 'shutdown error')
process.exit(1)
}
}
process.on('SIGTERM', () => { shutdown('SIGTERM') })
process.on('SIGINT', () => { shutdown('SIGINT') })
try {
await app.listen({ port, host: '0.0.0.0' })
} catch (err) {

View File

@@ -32,6 +32,10 @@ export class AIService {
private defaultModel: string
private mockMode: boolean
private consecutiveFailures = 0
private openaiKey = ''
private deepseekKey = ''
private openaiBaseUrl = ''
private deepseekBaseUrl = ''
constructor() {
this.cache = new LRUCache(200)
@@ -39,13 +43,25 @@ export class AIService {
this.retryMax = 3
this.defaultModel = process.env['AI_DEFAULT_MODEL'] ?? 'deepseek-chat'
this.mockMode = process.env['AI_MOCK'] === 'true'
this.openaiKey = process.env['OPENAI_API_KEY'] ?? ''
this.deepseekKey = process.env['DEEPSEEK_API_KEY'] ?? ''
this.openaiBaseUrl = process.env['OPENAI_BASE_URL'] ?? ''
this.deepseekBaseUrl = process.env['DEEPSEEK_BASE_URL'] ?? ''
this.initProviders()
}
reload(): void {
reload(updates?: Record<string, string>): void {
if (updates) {
if (updates.OPENAI_API_KEY !== undefined) this.openaiKey = updates.OPENAI_API_KEY
if (updates.DEEPSEEK_API_KEY !== undefined) this.deepseekKey = updates.DEEPSEEK_API_KEY
if (updates.OPENAI_BASE_URL !== undefined) this.openaiBaseUrl = updates.OPENAI_BASE_URL
if (updates.DEEPSEEK_BASE_URL !== undefined) this.deepseekBaseUrl = updates.DEEPSEEK_BASE_URL
if (updates.AI_MOCK !== undefined) this.mockMode = updates.AI_MOCK === 'true'
} else {
this.mockMode = process.env['AI_MOCK'] === 'true'
}
this.providers = {}
this.initProviders()
this.mockMode = process.env['AI_MOCK'] === 'true'
this.consecutiveFailures = 0
this.cache.clear()
}
@@ -58,13 +74,11 @@ export class AIService {
}
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 (this.openaiKey) {
this.providers['openai'] = createOpenAIProvider(this.openaiKey, this.openaiBaseUrl || undefined)
}
if (deepseekKey) {
this.providers['deepseek'] = createDeepSeekProvider(deepseekKey, process.env['DEEPSEEK_BASE_URL'])
if (this.deepseekKey) {
this.providers['deepseek'] = createDeepSeekProvider(this.deepseekKey, this.deepseekBaseUrl || undefined)
}
}
@@ -96,8 +110,8 @@ export class AIService {
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 recommendColorants(targetLab: { L: number; a: number; b: number }, availableColorants?: string[]): Promise<string> {
return this.execute('recommend-colorants', recommendColorantsPrompt(targetLab, availableColorants), { ttlMs: 1800_000 })
}
async extractFormula(text: string): Promise<string> {
@@ -163,14 +177,14 @@ export class AIService {
logAudit({
capability, modelName: model, promptHash,
tokensUsed: res.usage?.totalTokens, durationMs: duration,
}).catch(() => {})
}).catch(() => undefined)
if (opts.ttlMs > 0) {
this.cache.set(promptHash, res.content, opts.ttlMs)
}
return res.content
} catch (err) {
} catch {
if (attempt < this.retryMax - 1) {
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000))
}

View File

@@ -79,7 +79,9 @@ export function createOpenAIProvider(apiKey: string, baseURL?: string, defaultMo
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 { }
} catch {
void 0
}
}
}
}

View File

@@ -29,9 +29,12 @@ export function generateFormulaPrompt(constraints: {
]
}
export function recommendColorantsPrompt(targetLab: { L: number; a: number; b: number }): ChatMessage[] {
export function recommendColorantsPrompt(targetLab: { L: number; a: number; b: number }, availableColorants?: string[]): ChatMessage[] {
const colorantList = availableColorants && availableColorants.length > 0
? `可用色浆列表:${availableColorants.join('、')}。只能在以上列表中选择,不要推荐列表外的成分。`
: ''
return [
{ role: 'system', content: '你是一名化妆品色彩专家。根据目标 Lab 颜色值推荐色浆组合及比例。返回 JSON{"recommendations":[{"colorants":[{"name":"色浆名","ratio":0-1}],"predictedDeltaE":number,"confidence":0-1}]}' },
{ role: 'system', content: `你是一名化妆品色彩专家。根据目标 Lab 颜色值推荐色浆组合及比例。${colorantList}返回 JSON{"recommendations":[{"colorants":[{"name":"色浆名","ratio":0-1}],"predictedDeltaE":number,"confidence":0-1}]}` },
{ role: 'user', content: `目标颜色 Lab(${targetLab.L}, ${targetLab.a}, ${targetLab.b})` },
]
}

View File

@@ -0,0 +1,146 @@
import { prisma } from '../lib/prisma.js'
interface IngredientEntry {
ingredientId: string
name: string
category: string
percentage: number
unitPrice: number
}
interface OptimizationOption {
name: string
changes: Array<{
action: 'reduce' | 'replace' | 'add' | 'remove'
ingredient: string
newPercentage: number
reason: string
}>
predictedMetrics: { costEstimate: number }
reasoning: string
}
function findCheaperAlternative(
current: IngredientEntry,
available: IngredientEntry[],
): { alternative: IngredientEntry; newPercentage: number; savings: number } | null {
const candidates = available.filter(
a => a.category === current.category && a.unitPrice < current.unitPrice && a.ingredientId !== current.ingredientId,
)
if (candidates.length === 0) return null
const best = candidates.sort((a, b) => a.unitPrice - b.unitPrice)[0]!
const savings = current.percentage * (current.unitPrice - best.unitPrice) / 100
return { alternative: best, newPercentage: current.percentage, savings }
}
export async function exploreWithConstraints(params: {
baseFormulaId?: string
costLimit?: number
keepIngredients?: string[]
excludeIngredients?: string[]
}): Promise<OptimizationOption[]> {
const options: OptimizationOption[] = []
let baseIngredients: IngredientEntry[] = []
if (params.baseFormulaId) {
const version = await prisma.formulaVersion.findFirst({
where: { formulaId: params.baseFormulaId },
orderBy: { versionNumber: 'desc' },
include: {
ingredients: {
include: { ingredient: true, phase: true },
},
},
})
if (version) {
baseIngredients = version.ingredients.map(ing => ({
ingredientId: ing.ingredientId,
name: ing.ingredient.chineseName || ing.ingredient.inciName,
category: ing.ingredient.functionCategory,
percentage: Number(ing.percentage),
unitPrice: Number(ing.ingredient.unitPrice ?? 0),
}))
}
}
if (baseIngredients.length === 0) {
return []
}
const filtered = baseIngredients.filter(
ing => !params.excludeIngredients?.includes(ing.name),
)
const totalCost = filtered.reduce((s, i) => s + i.percentage * i.unitPrice / 100, 0)
if (params.costLimit && totalCost <= params.costLimit) {
options.push({
name: '当前配方(成本已达标)',
changes: filtered.map(ing => ({
action: 'reduce' as const,
ingredient: ing.name,
newPercentage: ing.percentage,
reason: '维持当前比例',
})),
predictedMetrics: { costEstimate: Math.round(totalCost * 10) / 10 },
reasoning: `当前成本 ${totalCost.toFixed(1)} 元/kg已在目标 ${params.costLimit} 元/kg 以内`,
})
}
const allIngredients = await prisma.ingredient.findMany({
select: { id: true, inciName: true, chineseName: true, functionCategory: true, unitPrice: true },
})
const allEntries: IngredientEntry[] = allIngredients.map(ing => ({
ingredientId: ing.id,
name: ing.chineseName || ing.inciName,
category: ing.functionCategory,
percentage: 0,
unitPrice: Number(ing.unitPrice ?? 0),
}))
for (let replaceCount = 1; replaceCount <= Math.min(3, filtered.length); replaceCount++) {
const replacements: Array<{ ingredient: string; from: string; to: string; newPct: number }> = []
let newTotalCost = 0
for (const ing of filtered) {
const candidate = findCheaperAlternative(ing, allEntries)
if (candidate && replacements.length < replaceCount) {
replacements.push({
ingredient: ing.name,
from: ing.name,
to: candidate.alternative.name,
newPct: candidate.newPercentage,
})
newTotalCost += candidate.newPercentage * candidate.alternative.unitPrice / 100
} else {
newTotalCost += ing.percentage * ing.unitPrice / 100
}
}
if (replacements.length === 0) continue
const savings = totalCost - newTotalCost
if (params.costLimit && newTotalCost > params.costLimit) continue
options.push({
name: `降本方案(${replacements.length}处替换)`,
changes: replacements.map(r => ({
action: 'replace',
ingredient: r.ingredient,
newPercentage: r.newPct,
reason: `${r.to}替代${r.from},降低单位成本`,
})),
predictedMetrics: { costEstimate: Math.round(newTotalCost * 10) / 10 },
reasoning: savings > 0
? `替换 ${replacements.length} 处成分,预估节省 ${savings.toFixed(1)} 元/kg`
: '调整比例以优化成本结构',
})
}
return options.slice(0, 5)
}

View File

@@ -0,0 +1,27 @@
import { getContext } from '../logging/context.js'
export interface AuditEntry {
action: 'create' | 'update' | 'delete'
resource: string
resourceId: string
userId: string
diff?: Record<string, unknown>
}
export const auditService = {
log(entry: AuditEntry): void {
const ctx = getContext()
if (!ctx) return
ctx.logger.info(
{
audit: true,
action: entry.action,
resource: entry.resource,
resourceId: entry.resourceId,
userId: entry.userId,
diff: entry.diff,
},
`${entry.action} ${entry.resource} ${entry.resourceId}`,
)
},
}

View File

@@ -0,0 +1,73 @@
export type ErrorCategory =
| 'validation'
| 'auth'
| 'not_found'
| 'conflict'
| 'business'
| 'upstream'
| 'internal'
export class AppError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly httpStatus: number,
public readonly category: ErrorCategory,
public readonly module?: string,
cause?: Error,
) {
super(message, { cause })
this.name = 'AppError'
}
toJSON() {
return {
code: this.code,
message: this.message,
category: this.category,
module: this.module,
}
}
}
export class ValidationError extends AppError {
constructor(code: string, message: string, module?: string) {
super(code, message, 400, 'validation', module)
this.name = 'ValidationError'
}
}
export class UnauthorizedError extends AppError {
constructor(code: string, message: string, module?: string) {
super(code, message, 401, 'auth', module)
this.name = 'UnauthorizedError'
}
}
export class ForbiddenError extends AppError {
constructor(code: string, message: string, module?: string) {
super(code, message, 403, 'auth', module)
this.name = 'ForbiddenError'
}
}
export class NotFoundError extends AppError {
constructor(code: string, message: string, module?: string) {
super(code, message, 404, 'not_found', module)
this.name = 'NotFoundError'
}
}
export class ConflictError extends AppError {
constructor(code: string, message: string, module?: string) {
super(code, message, 409, 'conflict', module)
this.name = 'ConflictError'
}
}
export class InternalError extends AppError {
constructor(code: string, message: string, module?: string, cause?: Error) {
super(code, message, 500, 'internal', module, cause)
this.name = 'InternalError'
}
}

View File

@@ -0,0 +1,33 @@
export const ErrorCodes = {
// 认证
AUTH_TOKEN_MISSING: 'AUTH_TOKEN_MISSING',
AUTH_TOKEN_INVALID: 'AUTH_TOKEN_INVALID',
AUTH_TOKEN_EXPIRED: 'AUTH_TOKEN_EXPIRED',
AUTH_USER_NOT_FOUND: 'AUTH_USER_NOT_FOUND',
AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS',
AUTH_USERNAME_EXISTS: 'AUTH_USERNAME_EXISTS',
AUTH_FORBIDDEN: 'AUTH_FORBIDDEN',
// 成分
INGREDIENT_NOT_FOUND: 'INGREDIENT_NOT_FOUND',
INGREDIENT_IN_USE: 'INGREDIENT_IN_USE',
// 配方
FORMULA_NOT_FOUND: 'FORMULA_NOT_FOUND',
FORMULA_PERCENTAGE_OUT_OF_RANGE: 'FORMULA_PERCENTAGE_OUT_OF_RANGE',
FORMULA_PERCENTAGE_TOTAL_INVALID: 'FORMULA_PERCENTAGE_TOTAL_INVALID',
FORMULA_DELETE_FAILED: 'FORMULA_DELETE_FAILED',
// 项目
PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND',
// AI
AI_UPSTREAM_ERROR: 'AI_UPSTREAM_ERROR',
AI_EXTRACTION_FAILED: 'AI_EXTRACTION_FAILED',
AI_PROVIDER_NOT_CONFIGURED: 'AI_PROVIDER_NOT_CONFIGURED',
// 内部
INTERNAL_ERROR: 'INTERNAL_ERROR',
} as const
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]

View File

@@ -0,0 +1,39 @@
import { AsyncLocalStorage } from 'async_hooks'
import type { FastifyBaseLogger } from 'fastify'
type Logger = FastifyBaseLogger
interface RequestContext {
requestId: string
userId?: string
logger: Logger
startTime: number
}
const storage = new AsyncLocalStorage<RequestContext>()
export function getContext(): RequestContext | undefined {
return storage.getStore()
}
export function getLogger(): Logger {
return storage.getStore()!.logger
}
export function getRequestId(): string {
const ctx = storage.getStore()
return ctx?.requestId ?? 'unknown'
}
export function getUserId(): string | undefined {
const ctx = storage.getStore()
return ctx?.userId
}
export function runWithContext<T>(ctx: RequestContext, fn: () => Promise<T>): Promise<T> {
return storage.run(ctx, fn)
}
export function createChildLogger(module: string): Logger {
return getLogger().child({ module })
}

View File

@@ -0,0 +1,40 @@
import { Counter, Histogram, Registry } from 'prom-client'
export const registry = new Registry()
export const httpRequestsTotal = new Counter({
name: 'http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'path', 'status'],
registers: [registry],
})
export const httpRequestDurationMs = new Histogram({
name: 'http_request_duration_ms',
help: 'HTTP request duration in ms',
labelNames: ['method', 'path'],
buckets: [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000],
registers: [registry],
})
export const appErrorsTotal = new Counter({
name: 'app_errors_total',
help: 'Total application errors',
labelNames: ['category', 'module', 'code'],
registers: [registry],
})
export const aiRequestsTotal = new Counter({
name: 'ai_requests_total',
help: 'Total AI requests',
labelNames: ['capability', 'provider', 'status'],
registers: [registry],
})
export const aiRequestDurationMs = new Histogram({
name: 'ai_request_duration_ms',
help: 'AI request duration in ms',
labelNames: ['capability'],
buckets: [100, 500, 1000, 2000, 5000, 10000, 30000, 60000],
registers: [registry],
})

View File

@@ -0,0 +1,32 @@
import type { FastifyRequest, FastifyReply } from 'fastify'
import { prisma } from '../../lib/prisma.js'
import { ForbiddenError, NotFoundError } from '../errors/app-error.js'
import { ErrorCodes } from '../errors/codes.js'
export function requireFormulaOwnership() {
return async (request: FastifyRequest<{ Params: { id: string } }>, _reply: FastifyReply) => {
const formula = await prisma.formula.findUnique({
where: { id: request.params.id },
select: { createdBy: true },
})
if (!formula) {
throw new NotFoundError(ErrorCodes.FORMULA_NOT_FOUND, '配方不存在', 'formulas')
}
const user = await prisma.user.findUnique({
where: { id: request.userId },
select: { role: true },
})
if (!user) {
throw new ForbiddenError(ErrorCodes.AUTH_FORBIDDEN, '权限不足')
}
if (user.role === 'admin') return
if (formula.createdBy !== request.userId) {
throw new ForbiddenError(ErrorCodes.AUTH_FORBIDDEN, '只能操作自己创建的配方')
}
}
}

View File

@@ -0,0 +1,19 @@
import type { FastifyRequest, FastifyReply } from 'fastify'
import { prisma } from '../../lib/prisma.js'
import { ForbiddenError } from '../errors/app-error.js'
import { ErrorCodes } from '../errors/codes.js'
export type Role = 'engineer' | 'admin'
export function requireRole(...roles: Role[]) {
return async (request: FastifyRequest, _reply: FastifyReply) => {
const user = await prisma.user.findUnique({
where: { id: request.userId },
select: { role: true },
})
if (!user || !roles.includes(user.role)) {
throw new ForbiddenError(ErrorCodes.AUTH_FORBIDDEN, '权限不足')
}
}
}

View File

@@ -6,6 +6,8 @@
"esModuleInterop": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",