企业级重构:四层模块化架构 + 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:
7
backend/.prettierrc
Normal file
7
backend/.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2
|
||||
}
|
||||
19
backend/Dockerfile
Normal file
19
backend/Dockerfile
Normal 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
16
backend/eslint.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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
1203
backend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
7
backend/runtime/config.json
Normal file
7
backend/runtime/config.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"AI_MOCK": "true",
|
||||
"OPENAI_API_KEY": "",
|
||||
"DEEPSEEK_API_KEY": "",
|
||||
"OPENAI_BASE_URL": "",
|
||||
"DEEPSEEK_BASE_URL": ""
|
||||
}
|
||||
23
backend/scripts/generate-openapi.ts
Normal file
23
backend/scripts/generate-openapi.ts
Normal 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)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
60
backend/src/lib/configStore.ts
Normal file
60
backend/src/lib/configStore.ts
Normal 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)
|
||||
}
|
||||
24
backend/src/lib/swagger.ts
Normal file
24
backend/src/lib/swagger.ts
Normal 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
|
||||
}
|
||||
27
backend/src/lib/validate.ts
Normal file
27
backend/src/lib/validate.ts
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
23
backend/src/modules/ai/ai.schema.ts
Normal file
23
backend/src/modules/ai/ai.schema.ts
Normal 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, '配方文本不能为空'),
|
||||
})
|
||||
91
backend/src/modules/auth/auth.route.ts
Normal file
91
backend/src/modules/auth/auth.route.ts
Normal 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)
|
||||
}
|
||||
11
backend/src/modules/auth/auth.schema.ts
Normal file
11
backend/src/modules/auth/auth.schema.ts
Normal 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, '密码不能为空'),
|
||||
})
|
||||
158
backend/src/modules/color/color.route.ts
Normal file
158
backend/src/modules/color/color.route.ts
Normal 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)
|
||||
}
|
||||
33
backend/src/modules/color/color.schema.ts
Normal file
33
backend/src/modules/color/color.schema.ts
Normal 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),
|
||||
})
|
||||
44
backend/src/modules/config/config.route.ts
Normal file
44
backend/src/modules/config/config.route.ts
Normal 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)
|
||||
}
|
||||
13
backend/src/modules/config/config.schema.ts
Normal file
13
backend/src/modules/config/config.schema.ts
Normal 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),
|
||||
})
|
||||
189
backend/src/modules/formulas/formulas.repository.ts
Normal file
189
backend/src/modules/formulas/formulas.repository.ts
Normal 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 } })
|
||||
})
|
||||
},
|
||||
}
|
||||
91
backend/src/modules/formulas/formulas.route.ts
Normal file
91
backend/src/modules/formulas/formulas.route.ts
Normal 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)
|
||||
}
|
||||
37
backend/src/modules/formulas/formulas.schema.ts
Normal file
37
backend/src/modules/formulas/formulas.schema.ts
Normal 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>
|
||||
67
backend/src/modules/formulas/formulas.service.ts
Normal file
67
backend/src/modules/formulas/formulas.service.ts
Normal 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 })
|
||||
},
|
||||
}
|
||||
@@ -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: {},
|
||||
36
backend/src/modules/health/health.route.ts
Normal file
36
backend/src/modules/health/health.route.ts
Normal 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())
|
||||
})
|
||||
}
|
||||
64
backend/src/modules/ingredients/ingredients.repository.ts
Normal file
64
backend/src/modules/ingredients/ingredients.repository.ts
Normal 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 } })
|
||||
},
|
||||
}
|
||||
77
backend/src/modules/ingredients/ingredients.route.ts
Normal file
77
backend/src/modules/ingredients/ingredients.route.ts
Normal 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)
|
||||
}
|
||||
30
backend/src/modules/ingredients/ingredients.schema.ts
Normal file
30
backend/src/modules/ingredients/ingredients.schema.ts
Normal 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>
|
||||
35
backend/src/modules/ingredients/ingredients.service.ts
Normal file
35
backend/src/modules/ingredients/ingredients.service.ts
Normal 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 }
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
24
backend/src/modules/projects/projects.repository.ts
Normal file
24
backend/src/modules/projects/projects.repository.ts
Normal 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 } })
|
||||
},
|
||||
}
|
||||
39
backend/src/modules/projects/projects.route.ts
Normal file
39
backend/src/modules/projects/projects.route.ts
Normal 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)
|
||||
}
|
||||
14
backend/src/modules/projects/projects.schema.ts
Normal file
14
backend/src/modules/projects/projects.schema.ts
Normal 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>
|
||||
25
backend/src/modules/projects/projects.service.ts
Normal file
25
backend/src/modules/projects/projects.service.ts
Normal 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 })
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() }
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})` },
|
||||
]
|
||||
}
|
||||
|
||||
146
backend/src/services/formulaOptimizer.ts
Normal file
146
backend/src/services/formulaOptimizer.ts
Normal 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)
|
||||
}
|
||||
27
backend/src/shared/audit/audit.service.ts
Normal file
27
backend/src/shared/audit/audit.service.ts
Normal 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}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
73
backend/src/shared/errors/app-error.ts
Normal file
73
backend/src/shared/errors/app-error.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
33
backend/src/shared/errors/codes.ts
Normal file
33
backend/src/shared/errors/codes.ts
Normal 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]
|
||||
39
backend/src/shared/logging/context.ts
Normal file
39
backend/src/shared/logging/context.ts
Normal 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 })
|
||||
}
|
||||
40
backend/src/shared/metrics/metrics.ts
Normal file
40
backend/src/shared/metrics/metrics.ts
Normal 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],
|
||||
})
|
||||
32
backend/src/shared/middleware/ownership.ts
Normal file
32
backend/src/shared/middleware/ownership.ts
Normal 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, '只能操作自己创建的配方')
|
||||
}
|
||||
}
|
||||
}
|
||||
19
backend/src/shared/middleware/rbac.ts
Normal file
19
backend/src/shared/middleware/rbac.ts
Normal 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, '权限不足')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
|
||||
Reference in New Issue
Block a user