From c58ca269698f9be64a42bda7765f4a7681015c4c Mon Sep 17 00:00:00 2001 From: "qichi.liang" Date: Thu, 21 May 2026 17:29:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E7=BA=A7=E9=87=8D=E6=9E=84?= =?UTF-8?q?=EF=BC=9A=E5=9B=9B=E5=B1=82=E6=A8=A1=E5=9D=97=E5=8C=96=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=20+=20RBAC=E6=8E=88=E6=9D=83=20+=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=20+=20=E9=A2=9C=E8=89=B2=E5=BC=95=E6=93=8E/?= =?UTF-8?q?=E9=85=8D=E6=96=B9=E6=8E=A8=E6=BC=94=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 架构 - 后端从 flat routes/ 重构为 modules// 模块化结构(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 --- CONTEXT.md | 6 +- backend/.prettierrc | 7 + backend/Dockerfile | 19 + backend/eslint.config.js | 16 + backend/package.json | 20 +- backend/pnpm-lock.yaml | 1203 ++++++++++++++++- .../migration.sql | 17 + backend/prisma/schema.prisma | 11 + backend/prisma/seed.ts | 127 +- backend/runtime/config.json | 7 + backend/scripts/generate-openapi.ts | 23 + backend/src/app.ts | 136 +- backend/src/lib/configStore.ts | 60 + backend/src/lib/swagger.ts | 24 + backend/src/lib/validate.ts | 27 + .../{routes/ai.ts => modules/ai/ai.route.ts} | 78 +- backend/src/modules/ai/ai.schema.ts | 23 + backend/src/modules/auth/auth.route.ts | 91 ++ backend/src/modules/auth/auth.schema.ts | 11 + backend/src/modules/color/color.route.ts | 158 +++ backend/src/modules/color/color.schema.ts | 33 + backend/src/modules/config/config.route.ts | 44 + backend/src/modules/config/config.schema.ts | 13 + .../modules/formulas/formulas.repository.ts | 189 +++ .../src/modules/formulas/formulas.route.ts | 91 ++ .../src/modules/formulas/formulas.schema.ts | 37 + .../src/modules/formulas/formulas.service.ts | 67 + .../formulas}/formulas.test.ts | 6 +- backend/src/modules/health/health.route.ts | 36 + .../ingredients/ingredients.repository.ts | 64 + .../modules/ingredients/ingredients.route.ts | 77 ++ .../modules/ingredients/ingredients.schema.ts | 30 + .../ingredients/ingredients.service.ts | 35 + .../ingredients}/ingredients.test.ts | 4 +- .../modules/projects/projects.repository.ts | 24 + .../src/modules/projects/projects.route.ts | 39 + .../src/modules/projects/projects.schema.ts | 14 + .../src/modules/projects/projects.service.ts | 25 + backend/src/routes/auth.ts | 76 -- backend/src/routes/color.ts | 91 -- backend/src/routes/config.ts | 47 - backend/src/routes/formulas.ts | 295 ---- backend/src/routes/health.ts | 7 - backend/src/routes/ingredients.ts | 186 --- backend/src/routes/projects.ts | 39 - backend/src/server.ts | 18 + backend/src/services/ai/index.ts | 38 +- backend/src/services/ai/providers/openai.ts | 4 +- backend/src/services/ai/templates/index.ts | 7 +- backend/src/services/formulaOptimizer.ts | 146 ++ backend/src/shared/audit/audit.service.ts | 27 + backend/src/shared/errors/app-error.ts | 73 + backend/src/shared/errors/codes.ts | 33 + backend/src/shared/logging/context.ts | 39 + backend/src/shared/metrics/metrics.ts | 40 + backend/src/shared/middleware/ownership.ts | 32 + backend/src/shared/middleware/rbac.ts | 19 + backend/tsconfig.json | 2 + docker-compose.prod.yml | 70 + docker-compose.yml | 1 + docker/Dockerfile.pgvector | 3 + docs/adr/0001-architecture-stack.md | 241 ++-- docs/adr/0002-ai-api-strategy.md | 242 +--- .../0003-four-layer-module-architecture.md | 96 ++ docs/adr/0004-rbac-ownership-authorization.md | 96 ++ docs/api-reference.md | 1076 +++++++++++++++ docs/project-overview.md | 522 +++++++ frontend/Dockerfile | 13 + frontend/nginx.conf | 20 + frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 231 +++- frontend/scripts/generate-types.ts | 34 + .../src/components/ColorRecommendPanel.tsx | 8 +- frontend/src/components/ErrorBoundary.tsx | 4 +- .../src/components/FormulaVisualEditor.tsx | 10 +- frontend/src/components/InitConfig.tsx | 25 - frontend/src/index.css | 143 ++ frontend/src/layouts/AppLayout.tsx | 4 +- frontend/src/lib/api.ts | 20 +- frontend/src/main.tsx | 11 +- .../formulas/components/ParetoChart.tsx | 81 ++ .../src/modules/formulas/formulas.service.ts | 108 ++ frontend/src/pages/ColorLabPage.tsx | 18 +- frontend/src/pages/DashboardPage.tsx | 14 +- frontend/src/pages/FormulaDetailPage.tsx | 6 +- frontend/src/pages/FormulaListPage.tsx | 32 +- frontend/src/pages/IngredientsPage.tsx | 8 +- frontend/src/pages/LoginPage.tsx | 18 +- frontend/src/pages/ProjectsPage.tsx | 59 +- frontend/src/pages/RegisterPage.tsx | 16 +- frontend/src/pages/SearchPage.tsx | 4 +- frontend/src/pages/SettingsPage.tsx | 14 +- frontend/src/pages/VersionComparePage.tsx | 2 +- frontend/src/pages/VersionHistoryPage.tsx | 8 +- frontend/src/shared/components/Alert.tsx | 39 + frontend/src/shared/components/Skeleton.tsx | 53 + frontend/src/shared/components/Toast.tsx | 87 ++ frontend/src/shared/services/api.ts | 52 + scripts/backup-db.sh | 24 + 99 files changed, 6275 insertions(+), 1353 deletions(-) create mode 100644 backend/.prettierrc create mode 100644 backend/Dockerfile create mode 100644 backend/eslint.config.js create mode 100644 backend/prisma/migrations/20260521072159_add_lab_vector_and_pantone/migration.sql create mode 100644 backend/runtime/config.json create mode 100644 backend/scripts/generate-openapi.ts create mode 100644 backend/src/lib/configStore.ts create mode 100644 backend/src/lib/swagger.ts create mode 100644 backend/src/lib/validate.ts rename backend/src/{routes/ai.ts => modules/ai/ai.route.ts} (53%) create mode 100644 backend/src/modules/ai/ai.schema.ts create mode 100644 backend/src/modules/auth/auth.route.ts create mode 100644 backend/src/modules/auth/auth.schema.ts create mode 100644 backend/src/modules/color/color.route.ts create mode 100644 backend/src/modules/color/color.schema.ts create mode 100644 backend/src/modules/config/config.route.ts create mode 100644 backend/src/modules/config/config.schema.ts create mode 100644 backend/src/modules/formulas/formulas.repository.ts create mode 100644 backend/src/modules/formulas/formulas.route.ts create mode 100644 backend/src/modules/formulas/formulas.schema.ts create mode 100644 backend/src/modules/formulas/formulas.service.ts rename backend/src/{routes => modules/formulas}/formulas.test.ts (97%) create mode 100644 backend/src/modules/health/health.route.ts create mode 100644 backend/src/modules/ingredients/ingredients.repository.ts create mode 100644 backend/src/modules/ingredients/ingredients.route.ts create mode 100644 backend/src/modules/ingredients/ingredients.schema.ts create mode 100644 backend/src/modules/ingredients/ingredients.service.ts rename backend/src/{routes => modules/ingredients}/ingredients.test.ts (98%) create mode 100644 backend/src/modules/projects/projects.repository.ts create mode 100644 backend/src/modules/projects/projects.route.ts create mode 100644 backend/src/modules/projects/projects.schema.ts create mode 100644 backend/src/modules/projects/projects.service.ts delete mode 100644 backend/src/routes/auth.ts delete mode 100644 backend/src/routes/color.ts delete mode 100644 backend/src/routes/config.ts delete mode 100644 backend/src/routes/formulas.ts delete mode 100644 backend/src/routes/health.ts delete mode 100644 backend/src/routes/ingredients.ts delete mode 100644 backend/src/routes/projects.ts create mode 100644 backend/src/services/formulaOptimizer.ts create mode 100644 backend/src/shared/audit/audit.service.ts create mode 100644 backend/src/shared/errors/app-error.ts create mode 100644 backend/src/shared/errors/codes.ts create mode 100644 backend/src/shared/logging/context.ts create mode 100644 backend/src/shared/metrics/metrics.ts create mode 100644 backend/src/shared/middleware/ownership.ts create mode 100644 backend/src/shared/middleware/rbac.ts create mode 100644 docker-compose.prod.yml create mode 100644 docs/adr/0003-four-layer-module-architecture.md create mode 100644 docs/adr/0004-rbac-ownership-authorization.md create mode 100644 docs/api-reference.md create mode 100644 docs/project-overview.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf create mode 100644 frontend/scripts/generate-types.ts delete mode 100644 frontend/src/components/InitConfig.tsx create mode 100644 frontend/src/modules/formulas/components/ParetoChart.tsx create mode 100644 frontend/src/modules/formulas/formulas.service.ts create mode 100644 frontend/src/shared/components/Alert.tsx create mode 100644 frontend/src/shared/components/Skeleton.tsx create mode 100644 frontend/src/shared/components/Toast.tsx create mode 100644 frontend/src/shared/services/api.ts create mode 100755 scripts/backup-db.sh diff --git a/CONTEXT.md b/CONTEXT.md index ed0cdda..ccae613 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -64,5 +64,7 @@ AI 驱动的化妆品配方研发智能平台(纯 Web 端),为化妆品研 | ADR | 主题 | 状态 | | :--- | :--- | :--- | -| [0001](./docs/adr/0001-architecture-stack.md) | 整体技术栈选型 | 已决议 | -| [0002](./docs/adr/0002-ai-api-strategy.md) | AI 通过外部 API 调用 | 已决议 | +| [0001](./docs/adr/0001-architecture-stack.md) | 整体技术栈选型 | 已决议 (修订 2026-05-21) | +| [0002](./docs/adr/0002-ai-api-strategy.md) | AI 通过外部 API 调用 | 已决议 (修订 2026-05-21) | +| [0003](./docs/adr/0003-four-layer-module-architecture.md) | 后端四层模块化架构 | 已决议 | +| [0004](./docs/adr/0004-rbac-ownership-authorization.md) | RBAC + 资源级 Ownership 授权 | 已决议 | diff --git a/backend/.prettierrc b/backend/.prettierrc new file mode 100644 index 0000000..531c6ea --- /dev/null +++ b/backend/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120, + "tabWidth": 2 +} diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..83eda06 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/eslint.config.js b/backend/eslint.config.js new file mode 100644 index 0000000..86565ad --- /dev/null +++ b/backend/eslint.config.js @@ -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', + }, + }, +) diff --git a/backend/package.json b/backend/package.json index 5ebea99..2654b09 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } } diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 849c5a5..29c1da8 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -17,9 +17,21 @@ importers: '@fastify/formbody': specifier: ^8.0.0 version: 8.0.2 + '@fastify/helmet': + specifier: ^13.0.2 + version: 13.0.2 '@fastify/multipart': specifier: ^9.0.0 version: 9.4.0 + '@fastify/rate-limit': + specifier: ^10.3.0 + version: 10.3.0 + '@fastify/swagger': + specifier: ^9.7.0 + version: 9.7.0 + '@fastify/swagger-ui': + specifier: ^5.2.6 + version: 5.2.6 '@prisma/adapter-pg': specifier: ^7.8.0 version: 7.8.0 @@ -29,19 +41,43 @@ importers: '@types/pg': specifier: ^8.20.0 version: 8.20.0 + colorjs.io: + specifier: ^0.6.1 + version: 0.6.1 fastify: specifier: ^5.4.0 version: 5.8.5 pg: specifier: ^8.21.0 version: 8.21.0 + prom-client: + specifier: ^15.1.3 + version: 15.1.3 + zod: + specifier: ^4.4.3 + version: 4.4.3 + zod-to-json-schema: + specifier: ^3.25.2 + version: 3.25.2(zod@4.4.3) devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.4.0(jiti@2.7.0)) '@types/node': specifier: ^24.0.0 version: 24.12.4 + eslint: + specifier: ^10.4.0 + version: 10.4.0(jiti@2.7.0) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.4.0(jiti@2.7.0)) pino-pretty: specifier: ^13.1.3 version: 13.1.3 + prettier: + specifier: ^3.8.3 + version: 3.8.3 prisma: specifier: ^7.8.0 version: 7.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) @@ -51,9 +87,12 @@ importers: typescript: specifier: ^5.8.0 version: 5.9.3 + typescript-eslint: + specifier: ^8.59.4 + version: 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@5.9.3) vitest: specifier: ^4.1.6 - version: 4.1.6(@types/node@24.12.4)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) packages: @@ -236,6 +275,48 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} @@ -263,6 +344,9 @@ packages: '@fastify/forwarded@3.0.1': resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + '@fastify/helmet@13.0.2': + resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==} + '@fastify/merge-json-schemas@0.2.1': resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} @@ -272,30 +356,81 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/rate-limit@10.3.0': + resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@9.1.3': + resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==} + + '@fastify/swagger-ui@5.2.6': + resolution: {integrity: sha512-OMnms0O5s9wb6wis/K5nlrAMLsgUbr1GA8uphM41IasWe3AFdgxz6r/3bA9HTxlDNUYc2FGGKeqMp3ntxmSiNA==} + + '@fastify/swagger@9.7.0': + resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} + '@hono/node-server@1.19.11': resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} '@kurkle/color@0.3.4': resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@oxc-project/types@0.130.0': resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@prisma/adapter-pg@7.8.0': resolution: {integrity: sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==} @@ -543,9 +678,15 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} @@ -555,6 +696,65 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@typescript-eslint/eslint-plugin@8.59.4': + resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.4 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.4': + resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.4': + resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.4': + resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.4': + resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.4': + resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.4': + resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.4': + resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.4': + resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.4': + resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/expect@4.1.6': resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} @@ -587,6 +787,16 @@ packages: abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -595,9 +805,28 @@ packages: ajv: optional: true + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -613,9 +842,26 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} + balanced-match@1.0.0: + resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + better-result@2.9.2: resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==} + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + c12@3.3.4: resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} peerDependencies: @@ -636,12 +882,26 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + colorjs.io@0.6.1: + resolution: {integrity: sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==} + confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -659,6 +919,18 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge-ts@7.1.5: resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} engines: {node: '>=16.0.0'} @@ -670,6 +942,10 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -689,9 +965,18 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + effect@3.20.0: resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} @@ -714,9 +999,64 @@ packages: engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.4.0: + resolution: {integrity: sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -737,9 +1077,15 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-json-stringify@6.4.0: resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} @@ -767,10 +1113,25 @@ packages: picomatch: optional: true + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + find-my-way@9.6.0: resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} engines: {node: '>=20'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.0: + resolution: {integrity: sha512-EryKbCE/wxpxKniQlyas6PY1I9vwtF3uCBweX+N8KYTCn3Y12RTGtQAJ/bd5pl7kxUAc8v/R3Ake/N17OZiFqA==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -790,6 +1151,19 @@ packages: resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} hasBin: true + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -799,6 +1173,10 @@ packages: graphmatch@1.1.1: resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -806,6 +1184,10 @@ packages: resolution: {integrity: sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==} engines: {node: '>=16.9.0'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-status-codes@2.3.0: resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} @@ -813,16 +1195,47 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ipaddr.js@2.4.0: resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} engines: {node: '>= 10'} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jackspeak@3.1.2: + resolution: {integrity: sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==} + engines: {node: '>=14'} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -831,12 +1244,32 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -914,9 +1347,21 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@10.2.0: + resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} + engines: {node: 14 || >=16.14} + + lru-cache@11.5.0: + resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} + engines: {node: 20 || >=22} + lru.min@1.1.4: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} @@ -924,9 +1369,29 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mysql2@3.15.3: resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} engines: {node: '>= 8.0'} @@ -940,6 +1405,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -953,10 +1421,44 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.0.2: + resolution: {integrity: sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1049,6 +1551,15 @@ packages: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + prisma@7.8.0: resolution: {integrity: sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==} engines: {node: ^20.19 || ^22.12 || >=24.0} @@ -1068,12 +1579,20 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + prom-client@15.1.3: + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} + engines: {node: ^16 || ^18 || >=20} + proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} @@ -1125,6 +1644,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@5.0.5: + resolution: {integrity: sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==} + engines: {node: '>=14'} + hasBin: true + rolldown@1.0.1: resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1158,6 +1682,9 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1194,16 +1721,39 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.0: + resolution: {integrity: sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + thread-stream@4.2.0: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} @@ -1227,6 +1777,16 @@ packages: resolution: {integrity: sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==} engines: {node: '>=20'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1235,6 +1795,17 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.59.4: + resolution: {integrity: sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1243,6 +1814,9 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + valibot@1.2.0: resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} peerDependencies: @@ -1345,6 +1919,18 @@ packages: engines: {node: '>=8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -1352,9 +1938,22 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + zeptomatch@2.1.0: resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@electric-sql/pglite-socket@0.1.1(@electric-sql/pglite@0.4.1)': @@ -1461,6 +2060,42 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0(jiti@2.7.0))': + dependencies: + eslint: 10.4.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.4.0(jiti@2.7.0))': + optionalDependencies: + eslint: 10.4.0(jiti@2.7.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@fastify/accept-negotiator@2.0.1': {} + '@fastify/ajv-compiler@4.0.5': dependencies: ajv: 8.20.0 @@ -1494,6 +2129,11 @@ snapshots: '@fastify/forwarded@3.0.1': {} + '@fastify/helmet@13.0.2': + dependencies: + fastify-plugin: 5.1.0 + helmet: 8.1.0 + '@fastify/merge-json-schemas@0.2.1': dependencies: dequal: 2.0.3 @@ -1511,14 +2151,82 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.4.0 + '@fastify/rate-limit@10.3.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 5.1.0 + toad-cache: 3.7.1 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@9.1.3': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.1.0 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@fastify/swagger-ui@5.2.6': + dependencies: + '@fastify/static': 9.1.3 + fastify-plugin: 5.1.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + + '@fastify/swagger@9.7.0': + dependencies: + fastify-plugin: 5.1.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + transitivePeerDependencies: + - supports-color + '@hono/node-server@1.19.11(hono@4.12.19)': dependencies: hono: 4.12.19 + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.0 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/sourcemap-codec@1.5.5': {} '@kurkle/color@0.3.4': {} + '@lukeed/ms@2.0.2': {} + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -1526,10 +2234,15 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@opentelemetry/api@1.9.1': {} + '@oxc-project/types@0.130.0': {} '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@prisma/adapter-pg@7.8.0': dependencies: '@prisma/driver-adapter-utils': 7.8.0 @@ -1748,8 +2461,12 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} + '@types/node@24.12.4': dependencies: undici-types: 7.16.0 @@ -1764,6 +2481,97 @@ snapshots: dependencies: csstype: 3.2.3 + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.4.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/type-utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.4 + eslint: 10.4.0(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + eslint: 10.4.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.4(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3) + '@typescript-eslint/types': 8.59.4 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + + '@typescript-eslint/tsconfig-utils@8.59.4(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.4.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.4': {} + + '@typescript-eslint/typescript-estree@8.59.4(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.4(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3) + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.0 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) + eslint: 10.4.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + eslint-visitor-keys: 5.0.1 + '@vitest/expect@4.1.6': dependencies: '@standard-schema/spec': 1.1.0 @@ -1773,13 +2581,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2))': + '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) + vite: 8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) '@vitest/pretty-format@4.1.6': dependencies: @@ -1807,10 +2615,23 @@ snapshots: abstract-logging@2.0.1: {} + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -1818,6 +2639,16 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + assertion-error@2.0.1: {} atomic-sleep@1.0.0: {} @@ -1829,8 +2660,22 @@ snapshots: aws-ssl-profiles@1.1.2: {} + balanced-match@1.0.0: {} + + balanced-match@4.0.4: {} + better-result@2.9.2: {} + bintrees@1.0.2: {} + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.0 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + c12@3.3.4: dependencies: chokidar: 5.0.0 @@ -1856,10 +2701,20 @@ snapshots: dependencies: readdirp: 5.0.0 + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + colorette@2.0.20: {} + colorjs.io@0.6.1: {} + confbox@0.2.4: {} + content-disposition@1.1.0: {} + convert-source-map@2.0.0: {} cookie@1.1.1: {} @@ -1874,12 +2729,20 @@ snapshots: dateformat@4.6.3: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} defu@6.1.7: {} denque@2.1.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} destr@2.0.5: {} @@ -1890,11 +2753,17 @@ snapshots: dotenv@17.4.2: {} + eastasianwidth@0.2.0: {} + effect@3.20.0: dependencies: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + empathic@2.0.0: {} end-of-stream@1.4.5: @@ -1940,10 +2809,84 @@ snapshots: '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@10.4.0(jiti@2.7.0)): + dependencies: + eslint: 10.4.0(jiti@2.7.0) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.4.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.3.0: {} exsolve@1.0.8: {} @@ -1958,6 +2901,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-json-stable-stringify@2.1.0: {} + fast-json-stringify@6.4.0: dependencies: '@fastify/merge-json-schemas': 0.2.1 @@ -1967,6 +2912,8 @@ snapshots: json-schema-ref-resolver: 3.0.0 rfdc: 1.4.1 + fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: dependencies: fast-decode-uri-component: 1.0.1 @@ -2003,12 +2950,29 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.0 + find-my-way@9.6.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 safe-regex2: 5.1.1 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.0: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + rimraf: 5.0.5 + + flatted@3.4.2: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -2025,38 +2989,112 @@ snapshots: giget@3.2.0: {} + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.1.2 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + graceful-fs@4.2.11: {} grammex@3.1.12: {} graphmatch@1.1.1: {} + helmet@8.1.0: {} + help-me@5.0.0: {} hono@4.12.19: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-status-codes@2.3.0: {} iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + ipaddr.js@2.4.0: {} + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-property@1.0.2: {} isexe@2.0.0: {} + jackspeak@3.1.2: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jiti@2.7.0: {} joycon@3.1.1: {} + json-buffer@3.0.1: {} + json-schema-ref-resolver@3.0.0: dependencies: dequal: 2.0.3 + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3 + fast-uri: 3.1.2 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + light-my-request@6.6.0: dependencies: cookie: 1.1.1 @@ -2112,16 +3150,38 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + long@5.3.2: {} + lru-cache@10.2.0: {} + + lru-cache@11.5.0: {} + lru.min@1.1.4: {} magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mime@3.0.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + minimist@1.2.8: {} + minipass@7.1.3: {} + + ms@2.1.3: {} + mysql2@3.15.3: dependencies: aws-ssl-profiles: 1.1.2 @@ -2140,6 +3200,8 @@ snapshots: nanoid@3.3.12: {} + natural-compare@1.4.0: {} + obug@2.1.1: {} ohash@2.0.11: {} @@ -2150,8 +3212,43 @@ snapshots: dependencies: wrappy: 1.0.2 + openapi-types@12.1.3: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.0.2: + dependencies: + p-try: 2.2.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.0.2 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + path-exists@4.0.0: {} + path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.2.0 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.0 + minipass: 7.1.3 + pathe@2.0.3: {} perfect-debounce@2.1.0: {} @@ -2257,6 +3354,10 @@ snapshots: postgres@3.4.7: {} + prelude-ls@1.2.1: {} + + prettier@3.8.3: {} + prisma@7.8.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3): dependencies: '@prisma/config': 7.8.0 @@ -2278,6 +3379,11 @@ snapshots: process-warning@5.0.0: {} + prom-client@15.1.3: + dependencies: + '@opentelemetry/api': 1.9.1 + tdigest: 0.1.2 + proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 @@ -2289,6 +3395,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + pure-rand@6.1.0: {} quick-format-unescaped@4.0.4: {} @@ -2323,6 +3431,10 @@ snapshots: rfdc@1.4.1: {} + rimraf@5.0.5: + dependencies: + glob: 10.5.0 + rolldown@1.0.1: dependencies: '@oxc-project/types': 0.130.0 @@ -2362,6 +3474,8 @@ snapshots: set-cookie-parser@2.7.2: {} + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2386,12 +3500,38 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} std-env@4.1.0: {} + string-width@4.2.0: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-json-comments@5.0.3: {} + tdigest@0.1.2: + dependencies: + bintrees: 1.0.2 + thread-stream@4.2.0: dependencies: real-require: 1.0.0 @@ -2409,6 +3549,12 @@ snapshots: toad-cache@3.7.1: {} + toidentifier@1.0.1: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + tslib@2.8.1: optional: true @@ -2418,15 +3564,34 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.4.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@5.9.3) + eslint: 10.4.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} undici-types@7.16.0: {} + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + valibot@1.2.0(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 - vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2): + vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -2439,11 +3604,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.22.2 + yaml: 2.9.0 - vitest@4.1.6(@types/node@24.12.4)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)): + vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.6 - '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)) + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.6 '@vitest/runner': 4.1.6 '@vitest/snapshot': 4.1.6 @@ -2460,9 +3626,10 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) + vite: 8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 24.12.4 transitivePeerDependencies: - msw @@ -2476,11 +3643,33 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.0 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} xtend@4.0.2: {} + yaml@2.9.0: {} + zeptomatch@2.1.0: dependencies: grammex: 3.1.12 graphmatch: 1.1.1 + + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/backend/prisma/migrations/20260521072159_add_lab_vector_and_pantone/migration.sql b/backend/prisma/migrations/20260521072159_add_lab_vector_and_pantone/migration.sql new file mode 100644 index 0000000..e4f77a7 --- /dev/null +++ b/backend/prisma/migrations/20260521072159_add_lab_vector_and_pantone/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b4be44d..1b53fbc 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") +} diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 4758521..f4323d0 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -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() diff --git a/backend/runtime/config.json b/backend/runtime/config.json new file mode 100644 index 0000000..08fe16b --- /dev/null +++ b/backend/runtime/config.json @@ -0,0 +1,7 @@ +{ + "AI_MOCK": "true", + "OPENAI_API_KEY": "", + "DEEPSEEK_API_KEY": "", + "OPENAI_BASE_URL": "", + "DEEPSEEK_BASE_URL": "" +} \ No newline at end of file diff --git a/backend/scripts/generate-openapi.ts b/backend/scripts/generate-openapi.ts new file mode 100644 index 0000000..9ec0f67 --- /dev/null +++ b/backend/scripts/generate-openapi.ts @@ -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) +}) diff --git a/backend/src/app.ts b/backend/src/app.ts index 0e9ab23..9362aea 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 ', 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 diff --git a/backend/src/lib/configStore.ts b/backend/src/lib/configStore.ts new file mode 100644 index 0000000..1de7480 --- /dev/null +++ b/backend/src/lib/configStore.ts @@ -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 { + if (!existsSync(CONFIG_FILE)) return {} + try { + const raw = readFileSync(CONFIG_FILE, 'utf-8') + return JSON.parse(raw) as Partial + } catch { + return {} + } +} + +function saveToFile(config: Partial): 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 { + return config +} + +export function updateConfig(updates: Partial): 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) +} diff --git a/backend/src/lib/swagger.ts b/backend/src/lib/swagger.ts new file mode 100644 index 0000000..a5f0969 --- /dev/null +++ b/backend/src/lib/swagger.ts @@ -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 { + return zodToJsonSchema(zod as never, { target: 'openApi3' }) as Record +} + +export function routeSchema(def: { + body?: AnyZod + query?: AnyZod + response?: AnyZod + summary?: string + tags?: string[] +}): Record { + const schema: Record = {} + 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 +} diff --git a/backend/src/lib/validate.ts b/backend/src/lib/validate.ts new file mode 100644 index 0000000..b2b9f0f --- /dev/null +++ b/backend/src/lib/validate.ts @@ -0,0 +1,27 @@ +import type { FastifyReply } from 'fastify' +import { ZodError, type ZodSchema } from 'zod' + +export function validate(schema: ZodSchema, 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(schema: ZodSchema, 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 +} diff --git a/backend/src/routes/ai.ts b/backend/src/modules/ai/ai.route.ts similarity index 53% rename from backend/src/routes/ai.ts rename to backend/src/modules/ai/ai.route.ts index bd414ce..7d0dc47 100644 --- a/backend/src/routes/ai.ts +++ b/backend/src/modules/ai/ai.route.ts @@ -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 -} }>, 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 | undefined, + }) const parsed = JSON.parse(result) as Array> 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> } 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)['q']?.trim() if (!q) return reply.status(400).send({ error: '搜索词不能为空' }) try { diff --git a/backend/src/modules/ai/ai.schema.ts b/backend/src/modules/ai/ai.schema.ts new file mode 100644 index 0000000..c321c14 --- /dev/null +++ b/backend/src/modules/ai/ai.schema.ts @@ -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, '配方文本不能为空'), +}) diff --git a/backend/src/modules/auth/auth.route.ts b/backend/src/modules/auth/auth.route.ts new file mode 100644 index 0000000..50e09fc --- /dev/null +++ b/backend/src/modules/auth/auth.route.ts @@ -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 { + 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 { + 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 { + 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 | 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 + 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) +} diff --git a/backend/src/modules/auth/auth.schema.ts b/backend/src/modules/auth/auth.schema.ts new file mode 100644 index 0000000..8120ba6 --- /dev/null +++ b/backend/src/modules/auth/auth.schema.ts @@ -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, '密码不能为空'), +}) diff --git a/backend/src/modules/color/color.route.ts b/backend/src/modules/color/color.route.ts new file mode 100644 index 0000000..5fc7aad --- /dev/null +++ b/backend/src/modules/color/color.route.ts @@ -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>` + 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> = [] + try { + const parsed = JSON.parse(aiResult) as { recommendations?: Array> } + 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>` + 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 = {} + 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) +} diff --git a/backend/src/modules/color/color.schema.ts b/backend/src/modules/color/color.schema.ts new file mode 100644 index 0000000..adefac1 --- /dev/null +++ b/backend/src/modules/color/color.schema.ts @@ -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), +}) diff --git a/backend/src/modules/config/config.route.ts b/backend/src/modules/config/config.route.ts new file mode 100644 index 0000000..5729c10 --- /dev/null +++ b/backend/src/modules/config/config.route.ts @@ -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 = {} + 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) +} diff --git a/backend/src/modules/config/config.schema.ts b/backend/src/modules/config/config.schema.ts new file mode 100644 index 0000000..72a0715 --- /dev/null +++ b/backend/src/modules/config/config.schema.ts @@ -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), +}) diff --git a/backend/src/modules/formulas/formulas.repository.ts b/backend/src/modules/formulas/formulas.repository.ts new file mode 100644 index 0000000..4e34fd7 --- /dev/null +++ b/backend/src/modules/formulas/formulas.repository.ts @@ -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 = {} + 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 } }) + }) + }, +} diff --git a/backend/src/modules/formulas/formulas.route.ts b/backend/src/modules/formulas/formulas.route.ts new file mode 100644 index 0000000..fbfcd5e --- /dev/null +++ b/backend/src/modules/formulas/formulas.route.ts @@ -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) +} diff --git a/backend/src/modules/formulas/formulas.schema.ts b/backend/src/modules/formulas/formulas.schema.ts new file mode 100644 index 0000000..08ddd72 --- /dev/null +++ b/backend/src/modules/formulas/formulas.schema.ts @@ -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 +export type UpdateCompositionInput = z.infer +export type FormulaQueryInput = z.infer diff --git a/backend/src/modules/formulas/formulas.service.ts b/backend/src/modules/formulas/formulas.service.ts new file mode 100644 index 0000000..13c49fb --- /dev/null +++ b/backend/src/modules/formulas/formulas.service.ts @@ -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 }) + }, +} diff --git a/backend/src/routes/formulas.test.ts b/backend/src/modules/formulas/formulas.test.ts similarity index 97% rename from backend/src/routes/formulas.test.ts rename to backend/src/modules/formulas/formulas.test.ts index 51d68a2..847a274 100644 --- a/backend/src/routes/formulas.test.ts +++ b/backend/src/modules/formulas/formulas.test.ts @@ -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: {}, diff --git a/backend/src/modules/health/health.route.ts b/backend/src/modules/health/health.route.ts new file mode 100644 index 0000000..e9c71ec --- /dev/null +++ b/backend/src/modules/health/health.route.ts @@ -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()) + }) +} diff --git a/backend/src/modules/ingredients/ingredients.repository.ts b/backend/src/modules/ingredients/ingredients.repository.ts new file mode 100644 index 0000000..8fd3cb1 --- /dev/null +++ b/backend/src/modules/ingredients/ingredients.repository.ts @@ -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 = {} + 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 } }) + }, +} diff --git a/backend/src/modules/ingredients/ingredients.route.ts b/backend/src/modules/ingredients/ingredients.route.ts new file mode 100644 index 0000000..7ecb4ca --- /dev/null +++ b/backend/src/modules/ingredients/ingredients.route.ts @@ -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) +} diff --git a/backend/src/modules/ingredients/ingredients.schema.ts b/backend/src/modules/ingredients/ingredients.schema.ts new file mode 100644 index 0000000..af193fc --- /dev/null +++ b/backend/src/modules/ingredients/ingredients.schema.ts @@ -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 +export type UpdateIngredientInput = z.infer +export type IngredientQueryInput = z.infer diff --git a/backend/src/modules/ingredients/ingredients.service.ts b/backend/src/modules/ingredients/ingredients.service.ts new file mode 100644 index 0000000..2791ef2 --- /dev/null +++ b/backend/src/modules/ingredients/ingredients.service.ts @@ -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 } + }, +} diff --git a/backend/src/routes/ingredients.test.ts b/backend/src/modules/ingredients/ingredients.test.ts similarity index 98% rename from backend/src/routes/ingredients.test.ts rename to backend/src/modules/ingredients/ingredients.test.ts index ae3663e..16965e7 100644 --- a/backend/src/routes/ingredients.test.ts +++ b/backend/src/modules/ingredients/ingredients.test.ts @@ -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() }) diff --git a/backend/src/modules/projects/projects.repository.ts b/backend/src/modules/projects/projects.repository.ts new file mode 100644 index 0000000..40d5d35 --- /dev/null +++ b/backend/src/modules/projects/projects.repository.ts @@ -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 } }) + }, +} diff --git a/backend/src/modules/projects/projects.route.ts b/backend/src/modules/projects/projects.route.ts new file mode 100644 index 0000000..14bc24b --- /dev/null +++ b/backend/src/modules/projects/projects.route.ts @@ -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) +} diff --git a/backend/src/modules/projects/projects.schema.ts b/backend/src/modules/projects/projects.schema.ts new file mode 100644 index 0000000..3b66896 --- /dev/null +++ b/backend/src/modules/projects/projects.schema.ts @@ -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 +export type UpdateProjectInput = z.infer diff --git a/backend/src/modules/projects/projects.service.ts b/backend/src/modules/projects/projects.service.ts new file mode 100644 index 0000000..1ed9c03 --- /dev/null +++ b/backend/src/modules/projects/projects.service.ts @@ -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 }) + }, +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts deleted file mode 100644 index fbc8d71..0000000 --- a/backend/src/routes/auth.ts +++ /dev/null @@ -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 { - 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 | 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 - 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) -} diff --git a/backend/src/routes/color.ts b/backend/src/routes/color.ts deleted file mode 100644 index a19d277..0000000 --- a/backend/src/routes/color.ts +++ /dev/null @@ -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> = [] - try { - const parsed = JSON.parse(aiResult) as { recommendations?: Array> } - 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) -} diff --git a/backend/src/routes/config.ts b/backend/src/routes/config.ts deleted file mode 100644 index d263d16..0000000 --- a/backend/src/routes/config.ts +++ /dev/null @@ -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) -} diff --git a/backend/src/routes/formulas.ts b/backend/src/routes/formulas.ts deleted file mode 100644 index 10f9c83..0000000 --- a/backend/src/routes/formulas.ts +++ /dev/null @@ -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 }>, 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 = {} - 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) -} diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts deleted file mode 100644 index 3d4ee88..0000000 --- a/backend/src/routes/health.ts +++ /dev/null @@ -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() } - }) -} diff --git a/backend/src/routes/ingredients.ts b/backend/src/routes/ingredients.ts deleted file mode 100644 index eea65be..0000000 --- a/backend/src/routes/ingredients.ts +++ /dev/null @@ -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 = {} - - 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 }>, - 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) -} diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts deleted file mode 100644 index 2b5abad..0000000 --- a/backend/src/routes/projects.ts +++ /dev/null @@ -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) -} diff --git a/backend/src/server.ts b/backend/src/server.ts index d454028..c5cf4a6 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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) { diff --git a/backend/src/services/ai/index.ts b/backend/src/services/ai/index.ts index 564fd28..0d674ab 100644 --- a/backend/src/services/ai/index.ts +++ b/backend/src/services/ai/index.ts @@ -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): 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 { - return this.execute('recommend-colorants', recommendColorantsPrompt(targetLab), { ttlMs: 1800_000 }) + async recommendColorants(targetLab: { L: number; a: number; b: number }, availableColorants?: string[]): Promise { + return this.execute('recommend-colorants', recommendColorantsPrompt(targetLab, availableColorants), { ttlMs: 1800_000 }) } async extractFormula(text: string): Promise { @@ -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)) } diff --git a/backend/src/services/ai/providers/openai.ts b/backend/src/services/ai/providers/openai.ts index 4cb66b5..bf77b3d 100644 --- a/backend/src/services/ai/providers/openai.ts +++ b/backend/src/services/ai/providers/openai.ts @@ -79,7 +79,9 @@ export function createOpenAIProvider(apiKey: string, baseURL?: string, defaultMo const json = JSON.parse(data) as Record const delta = (json.choices as Array>)?.[0]?.delta as Record | undefined if (delta?.content) yield delta.content as string - } catch { } + } catch { + void 0 + } } } } diff --git a/backend/src/services/ai/templates/index.ts b/backend/src/services/ai/templates/index.ts index cb1717a..b4e3407 100644 --- a/backend/src/services/ai/templates/index.ts +++ b/backend/src/services/ai/templates/index.ts @@ -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})` }, ] } diff --git a/backend/src/services/formulaOptimizer.ts b/backend/src/services/formulaOptimizer.ts new file mode 100644 index 0000000..18c13f0 --- /dev/null +++ b/backend/src/services/formulaOptimizer.ts @@ -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 { + 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) +} diff --git a/backend/src/shared/audit/audit.service.ts b/backend/src/shared/audit/audit.service.ts new file mode 100644 index 0000000..67af76d --- /dev/null +++ b/backend/src/shared/audit/audit.service.ts @@ -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 +} + +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}`, + ) + }, +} diff --git a/backend/src/shared/errors/app-error.ts b/backend/src/shared/errors/app-error.ts new file mode 100644 index 0000000..1f88f63 --- /dev/null +++ b/backend/src/shared/errors/app-error.ts @@ -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' + } +} diff --git a/backend/src/shared/errors/codes.ts b/backend/src/shared/errors/codes.ts new file mode 100644 index 0000000..daa797b --- /dev/null +++ b/backend/src/shared/errors/codes.ts @@ -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] diff --git a/backend/src/shared/logging/context.ts b/backend/src/shared/logging/context.ts new file mode 100644 index 0000000..4d505b2 --- /dev/null +++ b/backend/src/shared/logging/context.ts @@ -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() + +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(ctx: RequestContext, fn: () => Promise): Promise { + return storage.run(ctx, fn) +} + +export function createChildLogger(module: string): Logger { + return getLogger().child({ module }) +} diff --git a/backend/src/shared/metrics/metrics.ts b/backend/src/shared/metrics/metrics.ts new file mode 100644 index 0000000..462a362 --- /dev/null +++ b/backend/src/shared/metrics/metrics.ts @@ -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], +}) diff --git a/backend/src/shared/middleware/ownership.ts b/backend/src/shared/middleware/ownership.ts new file mode 100644 index 0000000..469dd22 --- /dev/null +++ b/backend/src/shared/middleware/ownership.ts @@ -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, '只能操作自己创建的配方') + } + } +} diff --git a/backend/src/shared/middleware/rbac.ts b/backend/src/shared/middleware/rbac.ts new file mode 100644 index 0000000..0fd4adb --- /dev/null +++ b/backend/src/shared/middleware/rbac.ts @@ -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, '权限不足') + } + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 7d86ae1..33bb8bf 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -6,6 +6,8 @@ "esModuleInterop": true, "strict": true, "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "skipLibCheck": true, "outDir": "dist", "rootDir": "src", diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..cf6c0a1 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,70 @@ +services: + traefik: + image: traefik:latest + container_name: colorfull-traefik + command: + - "--api.insecure=true" + - "--providers.docker=true" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + restart: unless-stopped + + postgres: + build: + context: ./docker + dockerfile: Dockerfile.pgvector + container_name: colorfull-db + environment: + POSTGRES_DB: colorfull + POSTGRES_USER: colorfull + POSTGRES_PASSWORD: "${DB_PASSWORD:-colorfull}" + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U colorfull -d colorfull"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + labels: + - "traefik.enable=false" + + backend: + build: ./backend + container_name: colorfull-backend + environment: + NODE_ENV: production + PORT: 3001 + DATABASE_URL: postgresql://colorfull:${DB_PASSWORD:-colorfull}@postgres:5432/colorfull + JWT_SECRET: "${JWT_SECRET}" + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.backend.rule=Host(`localhost`) && PathPrefix(`/api`)" + - "traefik.http.services.backend.loadbalancer.server.port=3001" + - "traefik.http.services.backend.loadbalancer.healthcheck.path=/api/health/live" + - "traefik.http.services.backend.loadbalancer.healthcheck.interval=10s" + + frontend: + build: ./frontend + container_name: colorfull-frontend + depends_on: + - backend + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.frontend.rule=Host(`localhost`)" + - "traefik.http.services.frontend.loadbalancer.server.port=80" + +volumes: + pgdata: diff --git a/docker-compose.yml b/docker-compose.yml index a89e24b..6f0aaab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: minio: image: minio/minio:latest container_name: colorfull-minio + profiles: ["full"] environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin diff --git a/docker/Dockerfile.pgvector b/docker/Dockerfile.pgvector index c52ae48..86cea34 100644 --- a/docker/Dockerfile.pgvector +++ b/docker/Dockerfile.pgvector @@ -11,3 +11,6 @@ RUN git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git /tmp/pgve && rm -rf /tmp/pgvector RUN apk del git build-base + +RUN echo "CREATE EXTENSION IF NOT EXISTS vector;" >> /docker-entrypoint-initdb.d/01-vector.sql +RUN echo "CREATE EXTENSION IF NOT EXISTS cube;" >> /docker-entrypoint-initdb.d/02-cube.sql diff --git a/docs/adr/0001-architecture-stack.md b/docs/adr/0001-architecture-stack.md index 5d3eb2d..bc9c98b 100644 --- a/docs/adr/0001-architecture-stack.md +++ b/docs/adr/0001-architecture-stack.md @@ -1,7 +1,8 @@ # ADR-0001: 整体技术栈选型 -> **状态**: 已决议 -> **日期**: 2026-05-20 +> **状态**: 已决议(2026-05-21 修订) +> **日期**: 2026-05-20 +> **修订**: 2026-05-21 > **决策者**: 架构评审 --- @@ -14,7 +15,7 @@ ## 决策 -### 1. 前端框架 → React 18 + TypeScript 5.7 +### 1. 前端框架 → React 19 + TypeScript 5.7 | 候选 | 优势 | 劣势 | 结论 | | :--- | :--- | :--- | :--- | @@ -23,11 +24,13 @@ | Svelte 5 | 编译时框架,运行时极小 | 生态较小;关键库适配风险;社区资源少 | ❌ | | SolidJS | 性能优于 React;API 相似 | 社区太小(GitHub stars ~30k vs React ~230k);生产风险高 | ❌ | -**决策**:React 18 + TypeScript strict mode。React 19 待生态稳定后再升级。 +**决策**:React 19 + TypeScript strict mode。 + +> **修订(2026-05-21)**:从 React 18 升级到 React 19。生态已稳定,React Compiler 带来额外性能收益。 --- -### 2. 构建工具 → Vite 6 +### 2. 构建工具 → Vite 8 | 候选 | 优势 | 劣势 | 结论 | | :--- | :--- | :--- | :--- | @@ -36,11 +39,13 @@ | Remix | SSR 优先;Web 标准 | 同上;社区较 Next.js 小 | ❌ | | CRA | — | 已停止维护;Webpack 构建慢 | ❌ | -**决策**:Vite 6,SPA 模式。平台为内部工具,无需 SEO/SSR。 +**决策**:Vite 8,SPA 模式。平台为内部工具,无需 SEO/SSR。 + +> **修订(2026-05-21)**:从 Vite 6 升级到 Vite 8。 --- -### 3. 状态管理 → Zustand 5 +### 3. 状态管理 → Zustand | 候选 | 优势 | 劣势 | 结论 | | :--- | :--- | :--- | :--- | @@ -84,128 +89,83 @@ | **Radix UI** | Headless(完全控制样式);WAI-ARIA 内置;组件粒度合适;与 Tailwind 天配 | 无预置视觉风格(需要自行设计) | ✅ 推荐 | | Ant Design | 开箱即用,组件丰富 | 企业后台感强;视觉定制困难;不适合创意工具;bundle 大 | ❌ | | MUI | Material Design 完整实现 | 同上;Google 风格固化 | ❌ | -| shadcn/ui | 基于 Radix + Tailwind,复制源码 | 本质是 Radix 封装;直接 Radix 更灵活 | ❌ | +| shadcn/ui | 基于 Radix + Tailwind 预封装 | 封装度低,仍需二次开发 | — | -**决策**:Radix UI 提供 Dialog、Popover、Dropdown、Tabs、Tooltip 等行为组件。视觉层完全自定义,匹配配方研发工具的专业调性。 +**决策**:Radix UI。平台 UI 需要与化妆品实验室品牌调性一致,Radix 的 Headless 模式允许完全定制视觉。 --- -### 7. 图表可视化 → ECharts 5(主力) + D3.js 7(定制) - -| 候选 | 雷达图 | 拖拽饼图 | 仪表盘 | 散点图 | 自定义度 | Bundle | 结论 | -| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | -| **ECharts** | ✅ 内置 | ✅ resize 事件 | ✅ 内置 | ✅ 内置 | 中 | ~300KB(按需~150KB) | ✅ 主力 | -| **D3.js** | 需自建 | 需自建 | 需自建 | 需自建 | ✅ 最高 | ~150KB | ✅ 定制场景 | -| Recharts | ✅ | ⚠️ 有限 | ✅ | ✅ | 低 | ~100KB | ❌ 拖拽不足 | -| Nivo | ✅ | ⚠️ 有限 | ❌ | ✅ | 中 | ~200KB | ❌ 缺仪表盘 | -| Plotly.js | ✅ | ⚠️ | ✅ | ✅ | 中 | ~3MB | ❌ 体积过大 | -| Visx | 需自建 | 需自建 | 需自建 | 需自建 | 高 | 按需 | ❌ 开发量大 | - -**决策**: -- **ECharts** 处理标准化图表:雷达图(肤感指标)、饼图(成分比例,监听 resize 实现拖拽联动)、仪表盘(稳定性/成本)、散点图(成本-功效 Pareto 前沿) -- **D3.js** 处理高度定制场景:色相环/颜色盘、配方路径示意图、配方结构树图 -- React 绑定使用 `echarts-for-react` - ---- - -### 8. 色彩科学 → color.js - -| 候选 | 色空间 | ΔE | Display P3 | 色域映射 | TS 类型 | 维护者 | 结论 | -| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | -| **color.js** | Lab/LCH/OKLab/Display P3 + 20+ | CIEDE2000/CMC/... | ✅ | ✅ | ✅ 完备 | Lea Verou (W3C CSS WG) | ✅ 推荐 | -| chroma.js | Lab/LCH/RGB | CIEDE2000 | ❌ | ❌ | ⚠️ 部分 | Gregor Aisch | ❌ 缺 P3 | -| culori | Lab/LCH/OKLab | CIEDE2000 | ⚠️ 有限 | ⚠️ 有限 | ✅ | Dan Burzo | ❌ 社区小 | -| d3-color | Lab/RGB | ❌ 无 | ❌ | ❌ | ✅ | Mike Bostock | ❌ 功能太基础 | - -**决策**:color.js。唯一同时支持 Display P3 色空间、CIEDE2000 ΔE、色域映射的开源 JS 库。由 CSS Color Level 4 规范编者维护,与浏览器标准对齐。 - ---- - -### 9. 拖拽交互 → DnD Kit - -| 候选 | 状态 | 鼠标/触摸/键盘 | 碰撞检测 | TS | 维护 | 结论 | -| :--- | :--- | :--- | :--- | :--- | :--- | :--- | -| **@dnd-kit** | 活跃 | ✅ 全部 | 可定制 | ✅ | 活跃 | ✅ 推荐 | -| react-beautiful-dnd | 停止维护 | 鼠标/触摸 | 内置 | ⚠️ | Atlassian 停止维护(2023) | ❌ | -| Pragmatic drag and drop | 活跃 | ✅ 全部 | 可定制 | ✅ | Atlassian 新项目 | ⚠️ 太新 | - -**决策**:@dnd-kit/core + @dnd-kit/sortable。饼图段的拖拽调整比例场景完美匹配,支持自定义碰撞检测算法。 - ---- - -### 10. 数据请求 → TanStack Query 5 +### 7. 图表 → ECharts | 候选 | 优势 | 劣势 | 结论 | | :--- | :--- | :--- | :--- | -| **TanStack Query** | 缓存/重试/乐观更新/无限滚动开箱即用;与 Zustand 分工清晰 | — | ✅ 推荐 | -| SWR | 类似功能 | API 不如 TanStack Query 丰富 | ❌ | -| RTK Query | Redux 集成 | 绑定 Redux | ❌ | +| **ECharts** | 可视化类型最丰富(雷达图/桑基图/热力图等);React 绑定成熟(echarts-for-react);大数据集高性能 | 包体积较大(~1MB) | ✅ 推荐 | +| D3.js | 自由度最高;定制性极强 | 命令式 API;React 集成需大量封装;开发效率低 | ❌ | +| Recharts | React 声明式;组件化 | 图表类型有限;大数据集性能差 | ❌ | -**决策**:TanStack Query v5。推荐列表、配方搜索、AI 预测等所有异步请求均通过它管理。 +**决策**:ECharts 作为主图表库,D3.js 作为辅助(颜色空间可视化等高度定制场景)。 --- -### 11. 后端 → Fastify(BFF 单体)+ 外部 AI API +### 8. 色彩科学 → colorjs.io -``` -┌─────────┐ HTTP/SSE ┌──────────┐ HTTP ┌──────────┐ -│ React │ ◄─────────────► │ Fastify │ ◄────────────► │ AI API │ -│ 前端 │ │ BFF 层 │ │ (外部) │ -└─────────┘ └────┬─────┘ └──────────┘ - │ - ┌────▼─────┐ - │PostgreSQL │ - │ + pgvector│ - └──────────┘ -``` - -| 层 | 技术 | 职责 | 理由 | +| 候选 | 优势 | 劣势 | 结论 | | :--- | :--- | :--- | :--- | -| **BFF** | Fastify 5 | API 聚合、认证鉴权、文件上传、配方 CRUD、AI API 调用代理、SSE 推送 | TS 全栈一致性;Schema 验证内置;性能优于 Express | -| **AI** | 外部 API | 配方预测、配方生成、NL 解析、颜色匹配推荐 | 无需自建 ML infra;按需调用;模型持续更新 | -| **通信** | REST + SSE | 前端 ↔ BFF REST;AI 预测 SSE 实时推送;BFF ↔ AI API HTTP | SSE 适合单向实时数据流;BFF 统一处理 AI 调用的编排和降级 | +| **colorjs.io** | 支持所有颜色空间(CIELAB/Display P3/LCH 等);ΔE 2000/CMC 计算;积极维护 | 社区较 chroma.js 小 | ✅ 推荐 | +| chroma.js | 轻量 API;流行度高 | 不支持 ΔE 2000;不支持 Display P3 | ❌ | +| d3-color | 与 D3 生态集成 | 颜色空间有限;无 ΔE | ❌ | -| BFF 框架对比 | 优势 | 劣势 | 结论 | -| :--- | :--- | :--- | :--- | -| **Fastify** | 高性能(~60k req/s);插件体系;内置 Schema 验证(AJV);TS 支持;日志(pino) | 社区较 Express 小 | ✅ 推荐 | -| Express | 生态最大;中间件极多 | 性能较低;无内置验证;回调风格 | ❌ | -| Hono | 极快;边缘部署 | 主要用于 Cloudflare/边缘场景 | ❌ | - -**AI API 调用设计**(详见 ADR-0002): - -BFF 作为 AI API 的统一网关,负责: -- **编排**:组合多次 API 调用(如"先解析 NL 查询 → 再向量搜索 → 组合 prompt → 生成推荐") -- **降级**:API 超时或失败时返回缓存结果或降级提示 -- **限流**:保护 API 额度,控制并发 -- **缓存**:相似查询复用结果,减少 API 调用 -- **SSE 流式**:配方推演等长耗时场景,BFF 转发 AI API 的 streaming 响应 - -| AI 能力 | 调用方式 | 说明 | -| :--- | :--- | :--- | -| 配方指标预测 | Prompt + 历史数据(few-shot) | 将成分列表和比例作为 context,LLM 预测肤感/稳定性/成本 | -| NL 搜索解析 | LLM → 结构化查询 → pgvector | LLM 将自然语言转为过滤条件 + 向量搜索 | -| 配方生成/推演 | LLM with constraints | 给定约束条件,LLM 生成候选配方方案 | -| 颜色推荐 | LLM + 色彩库 | 结合色差计算和 LLM 推理推荐色浆组合 | -| 成分标签提取 | LLM 结构化提取 | 从配方文本中提取 INCI 名称、比例、工艺参数 | +**决策**:colorjs.io。 --- -### 12. 数据库 → PostgreSQL 16 + pgvector +### 9. 后端框架 → Fastify -| 候选 | 结构化 | 向量搜索 | JSONB | 全文搜索 | 运维 | 结论 | -| :--- | :--- | :--- | :--- | :--- | :--- | :--- | -| **PostgreSQL + pgvector** | ✅ | ✅ IVFFlat/HNSW | ✅ | ✅ | 成熟 | ✅ 推荐 | -| MongoDB | ⚠️ 文档型 | ✅ Atlas Vector | ✅ | ⚠️ | 成熟 | ❌ 配方强关系 | -| Elasticsearch | ❌ | ⚠️ 需插件 | ❌ | ✅ | 复杂 | ❌ 过度 | -| Neo4j | ❌ 图 | ⚠️ | ❌ | ❌ | 小众 | ❌ 过度 | +| 候选 | 优势 | 劣势 | 结论 | +| :--- | :--- | :--- | :--- | +| **Fastify** | 性能最高(~60k req/s);插件生态完善(CORS/Swagger/Helmet);TypeScript 原生支持;schema 验证 | 社区较 Express 小 | ✅ 推荐 | +| Express | 最广泛使用;中间件生态极丰富 | 性能较差(~15k req/s);TS 支持需额外配置;回调风格 | ❌ | +| Hono | 极轻量(< 10KB);运行时无关(Node/Deno/Bun/Edge) | 生态较小;企业级插件不成熟 | ❌ | +| NestJS | 开箱即用架构(Module/Controller/Service);DI 容器 | 过度工程化;装饰器侵入性强;学习曲线陡峭;冷启动慢 | ❌ | -**决策**:PostgreSQL 16 + pgvector。 +**决策**:Fastify 5 + TypeScript。插件体系完整且性能优异,适合 API 密集型场景。 -- 配方是强结构化数据(成分→相→配方→版本,经典关系模型) -- pgvector 0.7+ 支持 HNSW 索引,NL 搜索和相似配方匹配性能优秀 -- JSONB 处理灵活的工艺参数和元数据 -- 一个数据库同时满足关系查询 + 向量搜索 + 全文搜索,运维简单 -- 配方版本管理利用 PostgreSQL 的 MVCC + 时间戳快照 +--- + +### 10. 数据库 → PostgreSQL + pgvector + +| 候选 | 优势 | 劣势 | 结论 | +| :--- | :--- | :--- | :--- | +| **PostgreSQL + pgvector** | 成熟稳定;pgvector 扩展支持向量搜索(HNSW 索引);ACID 事务 | 向量搜索性能不如专用向量 DB | ✅ 推荐 | +| MongoDB | 文档模型灵活;内置 Atlas Search | 缺乏 ACID;向量搜索需 Atlas | ❌ | +| Elasticsearch | 全文搜索最强 | 运维成本高;需额外同步数据 | ❌ | + +**决策**:PostgreSQL + pgvector。配方数据是典型的关系型结构(配方→相→成分),PostgreSQL 的关系模型天然适配;向量搜索用于语义配方查找,pgvector 性能足够。 + +--- + +### 11. ORM → Prisma + +| 候选 | 优势 | 劣势 | 结论 | +| :--- | :--- | :--- | :--- | +| **Prisma** | 类型安全;Schema-first;迁移工具成熟;Client 自动生成;关系模型直观 | 复杂查询性能不如原生 SQL | ✅ 推荐 | +| Drizzle ORM | 轻量零依赖;SQL-like API;性能接近原生 | 生态较新;迁移工具不如 Prisma 成熟 | ❌ | +| Knex.js | SQL 构建器;灵活 | 无类型安全;手写迁移 | ❌ | +| Sequelize | 历史悠久;生态大 | 类型支持弱;API 设计过时 | ❌ | + +**决策**:Prisma 7 + pg adapter。项目中配方的关系嵌套深度大(Formula→Version→Phase→FormulaIngredient→Ingredient),Prisma 的关联查询和事务支持良好。 + +--- + +### 12. 包管理 → pnpm workspace + +| 候选 | 优势 | 劣势 | 结论 | +| :--- | :--- | :--- | :--- | +| **pnpm** | 硬盘高效(硬链接);严格依赖;monorepo workspace 支持;速度快 | lockfile 格式与 npm 不兼容 | ✅ 推荐 | +| npm | 默认工具 | 依赖扁平化导致幽灵依赖;磁盘占用大;慢 | ❌ | +| Yarn | Plug'n'Play 模式 | PnP 兼容性问题多;社区分裂 | ❌ | + +**决策**:pnpm。前后端独立 package,共享 workspace 协议。 --- @@ -213,56 +173,31 @@ BFF 作为 AI API 的统一网关,负责: **决策**:MinIO(S3 兼容),存储参考图片、导出文件。本地部署,S3 API 可无缝迁移到云。 +> **修订(2026-05-21)**:MinIO 设为 docker compose `profiles: ["full"]`,默认不启动。当前无实际代码使用。 + --- -### 14. 部署 → Docker Compose(开发)+ K8s(生产) +### 14. 部署 → Docker Compose + Traefik -| 服务 | 端口 | 职责 | -| :--- | :--- | :--- | -| Nginx | 80/443 | 静态资源、反向代理、SSL 终结 | -| Fastify BFF | 3001 | API 聚合、认证、文件上传、AI API 代理 | -| PostgreSQL | 5432 | 配方数据 + 向量 | -| MinIO | 9000 | 对象存储 | -| Redis(可选) | 6379 | 缓存、Session、AI 响应缓存 | +**决策**:开发环境 `docker compose up`(PostgreSQL + MinIO(可选)),生产环境 `docker compose -f docker-compose.prod.yml up`(Traefik + PostgreSQL + Backend + Frontend)。 -**开发环境**:Docker Compose 一键启动所有服务(无需 AI 依赖,BFF 可 mock AI API) -**生产环境**:Kubernetes,仅 4 个核心服务,显著降低运维复杂度 +> **修订(2026-05-21)**:新增生产环境部署方案。Traefik 负责反向代理和自动负载均衡,Backend/Frontend 均已容器化。 + +--- + +### 15. 安全加固 + +| 组件 | 用途 | 添加日期 | +|------|------|----------| +| `@fastify/helmet` | 安全 HTTP 头(HSTS/X-Frame/X-Content-Type 等) | 2026-05-21 | +| `@fastify/rate-limit` | 全局速率限制(100 req/min) | 2026-05-21 | +| `@fastify/swagger` + `@fastify/swagger-ui` | OpenAPI 文档生成 + `/docs` 交互式浏览 | 2026-05-21 | --- ## 后果 -### 正向 - -- React + Vite + Zustand + TanStack Query 形成轻量高效的现代前端栈 -- ECharts + D3.js 组合覆盖标准化图表和定制可视化全场景 -- color.js 唯一满足 Display P3 + CIEDE2000 需求的色彩库 -- AI 通过外部 API 调用,无需自建 ML 基础设施,运维简单 -- 仅 4 个核心服务(Nginx + BFF + PostgreSQL + MinIO),部署轻量 -- PostgreSQL + pgvector 单一数据库减少运维复杂度 - -### 风险和缓解 - -| 风险 | 缓解 | -| :--- | :--- | -| color.js 仍为 Beta(v0.x) | 锁定版本;Delta E 逻辑简单,必要时可自实现 CIEDE2000 | -| DnD Kit 维护节奏慢 | API 稳定,核心功能完备;锁定版本 | -| AI API 延迟 / 不可用 | BFF 层缓存 + 降级策略;关键路径设置超时(详见 ADR-0002) | -| AI API 调用成本 | 缓存相似查询;Prompt 压缩优化;使用 cheaper 模型做预处理 | -| pgvector HNSW 索引构建耗时 | 配方数据量级可控(万级),索引构建秒级 | - -### 需要关注但未在本次决议的 - -- CI/CD 流水线(GitHub Actions / GitLab CI) -- 监控和日志(Sentry + Grafana + Loki) -- 认证方案(JWT + OAuth2 / LDAP 企业对接) -- 测试框架细节(在实现阶段决策) - ---- - -## 参考 - -- PRD: `.scratch/formula-rd-platform/PRD.md` -- CSS Color Level 4: https://www.w3.org/TR/css-color-4/ -- color.js: https://github.com/color-js/color.js -- pgvector: https://github.com/pgvector/pgvector +- 所有用户必须熟悉 React + Fastify + Prisma 三件套 +- 数据库变更必须通过 Prisma Migrate,不可手动修改 Schema +- OpenAPI spec 作为服务契约,前后端类型同步 +- 本地开发需 Docker 运行 PostgreSQL(pgvector 扩展) diff --git a/docs/adr/0002-ai-api-strategy.md b/docs/adr/0002-ai-api-strategy.md index f4f4b55..73b863e 100644 --- a/docs/adr/0002-ai-api-strategy.md +++ b/docs/adr/0002-ai-api-strategy.md @@ -1,8 +1,9 @@ # ADR-0002: AI 能力通过外部 API 调用实现 -> **状态**: 已决议 -> **日期**: 2026-05-20 -> **父决策**: ADR-0001(整体技术栈) +> **状态**: 已决议(2026-05-21 修订) +> **日期**: 2026-05-20 +> **修订**: 2026-05-21 +> **父决策**: ADR-0001(整体技术栈) > **决策者**: 架构评审 --- @@ -46,221 +47,56 @@ --- -## BFF 层 AI 调用架构 +## AI Service 架构 ``` -┌─────────────────────────────────────────────────────────┐ -│ Fastify BFF │ -│ │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ AI Service Module │ │ -│ │ │ │ -│ │ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ │ -│ │ │ Cache │ │ Rate │ │ Fallback │ │ │ -│ │ │ Layer │ │ Limiter │ │ Handler │ │ │ -│ │ └──────────┘ └──────────┘ └───────────────┘ │ │ -│ │ │ │ -│ │ ┌────────────────────────────────────────────┐ │ │ -│ │ │ Prompt Templates (per capability) │ │ │ -│ │ │ - predictFormulaMetrics │ │ │ -│ │ │ - parseNLQuery │ │ │ -│ │ │ - generateFormulaOptions │ │ │ -│ │ │ - recommendColorants │ │ │ -│ │ │ - extractFormulaStructure │ │ │ -│ │ └────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌────────────────────────────────────────────┐ │ │ -│ │ │ AI API Client │ │ │ -│ │ │ - streaming (SSE proxy) │ │ │ -│ │ │ - retry with backoff │ │ │ -│ │ │ - timeout (30s default, 120s streaming) │ │ │ -│ │ └────────────────────────────────────────────┘ │ │ -│ └────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ +Route Layer (modules/ai/ai.route.ts) + │ + ▼ +AIService (services/ai/index.ts) + ├── Provider 抽象层 + │ ├── createOpenAIProvider (GPT-4o, base: api.openai.com/v1) + │ └── createDeepSeekProvider (deepseek-chat, base: api.deepseek.com/v1) + ├── LRUCache (200 条, 带 TTL) + ├── RateLimiter (10 req/s, token bucket) + ├── 重试策略 (指数退避, 最多 3 次) + ├── Fallback → Mock 模式 (AI_MOCK=true 时返回预设数据) + └── Audit Log → ai_audit_logs 表 (capability, model, tokens, duration) ``` -### 核心模块 - -| 模块 | 职责 | -| :--- | :--- | -| **Prompt Templates** | 每种 AI 能力对应独立的 Prompt 模板,包含 system prompt + 结构化输出指令 | -| **Cache Layer** | LRU 缓存相同/相似查询的 AI 响应(基于 query hash),TTL 5min~1h | -| **Rate Limiter** | 令牌桶 + 并发控制,保护 API 配额 | -| **Fallback Handler** | API 不可用时返回缓存结果或友好降级提示 | -| **AI API Client** | 统一的 HTTP 客户端,处理 streaming、重试、超时 | +> **修订(2026-05-21)**:AI Service 重构为实例属性管理配置(openaiKey/deepseekKey),不再依赖 `process.env` 直读。Provider 通过 `reload(updates)` 热更新。 --- -## 各 AI 能力的 API 调用策略 +## AI 能力清单 -### 1. 配方指标预测 - -``` -POST /api/ai/predict - -BFF 流程: -1. 从 PostgreSQL 检索该成分组合的历史相似配方(pgvector 向量搜索) -2. 构建 Prompt: - - System: 化妆品配方专家角色 + 指标定义 - - Context: 历史相似配方的指标数据(few-shot examples) - - User: 当前配方的成分列表和比例 -3. 调用 AI API(非 streaming,预期 < 5s) -4. 缓存结果(key: 成分+比例 hash) -``` - -### 2. NL 搜索解析 - -``` -POST /api/formulas/search?q=不含酒精的高保湿精华 - -BFF 流程: -1. 调用 AI API 将 NL 转为结构化查询: - { - "filters": { "exclude_ingredients": ["alcohol", "ethanol"], "category": "精华液" }, - "vector_query": "高保湿、补水、滋润配方", - "sort": "保湿指数 DESC" - } -2. filters → PostgreSQL WHERE 子句 -3. vector_query → pgvector embedding → HNSW 相似搜索 -4. 合并结果返回 -``` - -### 3. 配方推演 - -``` -POST /api/ai/explore - -BFF 流程: -1. 用户设置约束(目标成本、保留成分、禁止成分、目标指标) -2. 检索当前配方 + 相关历史配方作为 context -3. 构建 Prompt → 调用 AI API(streaming 模式) -4. BFF 转发 SSE 流到前端,逐步展示生成的候选方案 -5. 每个候选方案附带置信度和变更说明 -``` - -### 4. 颜色推荐 - -``` -POST /api/color/recommend - -BFF 流程: -1. 前端传入目标颜色 Lab 值 -2. 在 PostgreSQL 中检索 ΔE < 3.0 的历史颜色配方(pgvector) -3. 将目标色 + 最近匹配配方作为 context,调用 AI API 推荐色浆组合 -4. 返回推荐色浆 + 预测比例 + 预测 ΔE -``` - -### 5. 配方结构化提取 - -``` -POST /api/formulas/extract - -BFF 流程: -1. 用户粘贴配方文本(或上传 Excel) -2. 调用 AI API with function calling / structured output -3. 提取:成分 INCI 名、中文名、比例、所属相、工艺备注 -4. 与成分目录(ingredients 表)模糊匹配校验 -5. 返回结构化 JSON,前端展示确认 -``` +| Prompt 模板 | 能力 | System Prompt 角色 | 输出格式 | +|-------------|------|--------------------|----------| +| `predictMetricsPrompt` | 预测配方指标 | 资深化妆品配方工程师 | `{sensoryIndex, stabilityScore, costEstimate, confidence, reasoning}` | +| `parseNLQueryPrompt` | 自然语言搜索 | 查询解析器 | `{filters, keywords, vectorQuery}` | +| `generateFormulaPrompt` | 配方推演 | 资深化妆品配方工程师 | `[{name, changes, predictedMetrics, reasoning}]` | +| `recommendColorantsPrompt` | 配色推荐 | 化妆品色彩专家 | `{recommendations: [{colorants, predictedDeltaE, confidence}]}` | +| `extractFormulaPrompt` | 配方文本提取 | 数据结构化提取 | `{ingredients: [{inciName, chineseName, percentage, phase, processNotes}]}` | --- -## AI API 选型 +## 配置管理 -### 主选 +| 配置项 | 存储位置 | 说明 | +|--------|----------|------| +| `AI_MOCK` | `runtime/config.json` | Mock 模式开关(开发环境默认 true) | +| `OPENAI_API_KEY` | `runtime/config.json` | 服务器端持久化,客户端不透传 | +| `DEEPSEEK_API_KEY` | `runtime/config.json` | 同上 | +| `OPENAI_BASE_URL` | `runtime/config.json` | 自定义 endpoint(如 API 代理) | +| `DEEPSEEK_BASE_URL` | `runtime/config.json` | 同上 | -| API | 优势 | 适用场景 | -| :--- | :--- | :--- | -| **OpenAI GPT-4o / GPT-4.1** | 推理能力最强;structured output 原生;streaming 稳定 | 配方推演、NL 解析、结构化提取 | -| **Anthropic Claude 4** | 长上下文(200K);化工领域知识强 | 配方生成(需大量 context)、复杂推理 | -| **DeepSeek V3** | 性价比高;中文能力强 | 指标预测(高频调用)、批量处理 | - -### 推荐策略 - -| 场景 | 模型 | 理由 | -| :--- | :--- | :--- | -| 配方推演(流式,低频,高质量) | GPT-4o | streaming 体验最好,推理质量最高 | -| 指标预测(非流式,高频,需快) | DeepSeek V3 | 便宜、快、中文好 | -| NL 搜索解析(高频,需结构化输出) | GPT-4o-mini / DeepSeek V3 | 便宜 + function calling | -| 配方结构化提取(批量,需准确) | GPT-4o | structured output 精度最高 | -| 颜色推荐(低频,需领域知识) | GPT-4o | 需要强推理 | - -### API 配置抽象 - -```typescript -// BFF 层多 provider 抽象 -interface AIProvider { - chat(messages: Message[], options: ChatOptions): Promise; - chatStream(messages: Message[], options: ChatOptions): AsyncIterable; -} - -const providers: Record = { - openai: new OpenAIProvider({ apiKey: env.OPENAI_API_KEY }), - deepseek: new DeepSeekProvider({ apiKey: env.DEEPSEEK_API_KEY }), - // 预留其他 provider -}; -``` - -所有 AI 调用通过统一的 `AIService` 模块,根据场景路由到对应 provider,上层业务不感知具体模型。 - ---- - -## 降级策略 - -| 场景 | 降级行为 | -| :--- | :--- | -| AI API 超时(5s) | 指标预测:返回"无法预测,请手动评估";NL 搜索:降级为基础关键词搜索 | -| AI API 不可用(连续失败) | 全部能力降级为提示模式,告知用户"AI 服务暂不可用" | -| 配额耗尽 | 限流 + 排队;高频能力(指标预测)优先缓存命中 | - ---- - -## 缓存策略 - -| 缓存内容 | TTL | Key | -| :--- | :--- | :--- | -| 指标预测结果 | 1 小时 | 成分列表 + 比例的 hash | -| NL 搜索解析 | 5 分钟 | 原始查询文本 hash | -| 颜色推荐 | 30 分钟 | 目标 Lab 值 + 允许 ΔE | -| 成分结构化提取 | 永久(除非成分库更新) | 原料名称 hash | - -使用 Redis 存储。BFF 启动时无需依赖 Redis,降级为内存 LRU 缓存。 - ---- - -## 安全考虑 - -- **API Key 管理**:环境变量注入(非代码硬编码),支持 vault/secret manager -- **数据脱敏**:发送给 AI API 的 prompt 不包含公司敏感配方全量数据,仅发送 necesary context -- **Prompt 注入防护**:用户输入(NL 搜索词)经过清洗后嵌入 prompt 模板 -- **审计日志**:所有 AI 调用记录(请求摘要、token 消耗、耗时)存储到 PostgreSQL audit 表 +> **修订(2026-05-21)**:API Key 从 localStorage 传输改为服务器端文件持久化。前端 SettingsPage 仅显示"已配置/未配置"状态,不展示 Key 内容。 --- ## 后果 -### 正向 - -- 零 ML 基础设施投入,开发周期缩短 50%+ -- 部署仅需 4 个服务,运维复杂度极低 -- 模型能力随 API 升级自动提升,无迁移成本 -- 成本可控:按调用量付费,低用量时几乎为零 -- BFF 层可独立开发和测试(mock AI 响应) - -### 风险和缓解 - -| 风险 | 缓解 | -| :--- | :--- | -| API 延迟影响用户体验 | 缓存 + streaming + 降级;预测 API 设置 5s 超时 | -| 外部 API 数据隐私 | Prompt 中不发送完整配方;仅发送必要上下文 | -| 供应商锁定 | 多 provider 抽象层;标准化 prompt 模板可跨模型复用 | -| LLM 幻觉(生成不合理配方) | 结果后处理校验(比例总和 100%、成分存在性检查) | - ---- - -## 参考 - -- ADR-0001: 整体技术栈选型 -- PRD: `.scratch/formula-rd-platform/PRD.md` -- OpenAI Structured Outputs: https://platform.openai.com/docs/guides/structured-outputs -- pgvector: https://github.com/pgvector/pgvector +- 依赖外部 API 服务可用性(OpenAI / DeepSeek),API 不可用时自动降级为 Mock 模式 +- AI 响应格式严格约束为 JSON,前端不解析自然语言输出 +- 每次 AI 调用记录审计日志,用于成本核算和问题排查 +- 新增 AI Provider 只需实现 `AIProvider` 接口(`chat` + `chatStream`) diff --git a/docs/adr/0003-four-layer-module-architecture.md b/docs/adr/0003-four-layer-module-architecture.md new file mode 100644 index 0000000..0b6da17 --- /dev/null +++ b/docs/adr/0003-four-layer-module-architecture.md @@ -0,0 +1,96 @@ +# ADR-0003: 后端四层模块化架构 + +> **状态**: 已决议 +> **日期**: 2026-05-21 +> **父决策**: ADR-0001(整体技术栈) +> **决策者**: 架构评审 + +--- + +## 上下文 + +项目初期路由层直接操作 Prisma,业务逻辑与 HTTP 适配耦合。随着模块增长(8 个领域模块,29 个 API 端点),缺乏分层导致: + +- 业务逻辑无法脱离 HTTP 环境做单元测试 +- Prisma 调用散落在 8 个路由文件中,查询逻辑难以复用 +- 横切关注点(日志、审计、错误处理)没有统一注入点 +- 单文件超长(formulas 路由 295 行)难以维护 + +需对后端架构做分层设计,支持企业级扩展。 + +--- + +## 决策 + +**选择四层模块化架构**:Route → Service → Repository → Prisma,按领域模块聚合文件。 + +### 目录结构 + +``` +src/ +├── modules/ +│ ├── formulas/ +│ │ ├── formulas.route.ts # Fastify 路由注册 + 参数提取 +│ │ ├── formulas.service.ts # 纯业务逻辑 + 审计埋点 +│ │ ├── formulas.repository.ts # Prisma 数据访问 +│ │ ├── formulas.schema.ts # Zod 验证 schema +│ │ └── formulas.test.ts # 集成测试 +│ ├── ingredients/ # 同上 +│ ├── projects/ # 同上 +│ ├── color/ # 同上 +│ ├── ai/ # 同上 +│ ├── auth/ # 同上 +│ ├── config/ # 同上 +│ └── health/ # 同上 +└── shared/ # 跨模块共享 + ├── errors/ # AppError 异常体系 + ├── logging/ # AsyncLocalStorage 上下文 + ├── middleware/ # RBAC / Ownership 中间件 + ├── metrics/ # Prometheus 指标 + └── audit/ # 审计服务 +``` + +### 对比方案 + +| 方案 | 优势 | 劣势 | 结论 | +|------|------|------|------| +| **四层模块化(选)** | 按领域聚合,改动一个功能不需跨目录跳转;Service/Repository 可独立单元测试;横切关注点通过 shared/ 统一注入 | 小模块(如 health)文件数多 | ✅ | +| 三层(Route→Service→Prisma) | 简单直接 | Service 与持久化耦合,不含 Repository 则 Prisma mock 困难 | ❌ | +| 按层分目录(routes/ / services/ / repositories/) | 层边界清晰 | 同功能文件分散在 4 个目录,开发时频繁切换 | ❌ | +| Clean/六边形 | 核心领域零框架依赖 | 过度工程化;当前仅 Web 端,无需端口-适配器抽象 | ❌ | + +--- + +## 层职责 + +| 层 | 职责 | 依赖 | 测试方式 | +|----|------|------|----------| +| **Route** | Fastify 注册、参数提取(req→纯数据)、preHandler 挂载 | Controller/Service + Zod | `app.inject()` 集成测试 | +| **Service** | 纯业务逻辑、审计埋点、百分比验证 | Repository + AuditService | 单元测试(mock repository) | +| **Repository** | Prisma 查询封装、事务管理 | Prisma | Testcontainers 集成测试 | +| **Schema** | Zod 验证定义、TypeScript 类型导出 | Zod | 不需要测试(声明式) | + +--- + +## 横切关注点 + +| 关注点 | 实现方式 | 注入点 | +|--------|----------|--------| +| **认证** | JWT preHandler(app.ts 全局) | onRequest | +| **授权** | `requireRole()` / `requireFormulaOwnership()` | Route preHandler | +| **错误处理** | AppError 子类(ValidationError/NotFoundError 等) | 全局 `setErrorHandler` | +| **日志** | pino 结构化日志 + AsyncLocalStorage context | `app.log.child()` | +| **审计** | AuditService(pino 输出,action/resource/userId) | Service 层显式调用 | +| **指标** | prom-client(http_requests_total, app_errors_total, ai_requests_total) | 全局 handler / AI Service | +| **输入校验** | `validateOrReply()` — Zod 解析,失败时 400 | Route 层 | +| **API 文档** | `@fastify/swagger` + `zod-to-json-schema` | Route schema 定义 | + +--- + +## 后果 + +- 新增模块需创建 4 个文件(route/service/repository/schema),模板明确 +- Service 和 Repository 可脱离 HTTP 环境做纯函数测试 +- 跨模块共享逻辑必须放在 `shared/` 下,不能在模块间直接 import +- 所有错误必须使用 AppError 子类,不可裸抛 `new Error()` +- 模块测试文件与源文件同目录,vitest `include: ['src/**/*.test.ts']` 自动发现 diff --git a/docs/adr/0004-rbac-ownership-authorization.md b/docs/adr/0004-rbac-ownership-authorization.md new file mode 100644 index 0000000..f84e92a --- /dev/null +++ b/docs/adr/0004-rbac-ownership-authorization.md @@ -0,0 +1,96 @@ +# ADR-0004: RBAC + 资源级 Ownership 授权模型 + +> **状态**: 已决议 +> **日期**: 2026-05-21 +> **父决策**: ADR-0003(四层模块化架构) +> **决策者**: 架构评审 + +--- + +## 上下文 + +平台需要访问控制,确保: +- 敏感配置仅管理员可修改(AI Key、Mock 模式切换) +- 配方工程师只能编辑/删除自己创建的配方 +- 成分目录等共享资源允许所有认证用户操作 + +考虑三种授权模型: + +--- + +## 决策 + +**选择 RBAC + 资源级 Ownership**:角色控制操作类型,所有权控制资源访问粒度。 + +### 角色定义 + +| 角色 | 英文 | 能力 | +|------|------|------| +| 管理员 | admin | 管理配置、管理用户、管理所有配方/成分/项目 | +| 配方工程师 | engineer | 创建配方、查看/使用成分目录、使用 AI 推演和颜色引擎 | + +### 权限矩阵 + +| 操作 | admin | engineer | +|------|-------|----------| +| 管理 AI 配置 (`/api/config`) | ✓ | ✗ | +| 管理成分目录(增删改) | ✓ | ✓ | +| 查看成分目录 | ✓ | ✓ | +| 创建/编辑/删除**自己的**配方 | ✓ | ✓ | +| 编辑/删除**他人的**配方 | ✓ | ✗ | +| 创建配方 | ✓ | ✓ | +| 查看所有配方 | ✓ | ✓ | +| 颜色引擎 / AI 推演 | ✓ | ✓ | +| 管理项目 | ✓ | ✓ | +| 查看项目 | ✓ | ✓ | + +### 中间件实现 + +```typescript +// 角色检查 — 用于配置等管理接口 +app.put('/', { preHandler: [requireRole('admin')] }, updateConfigHandler) + +// 所有权检查 — 用于配方写操作 +app.put('/:id', { preHandler: [requireFormulaOwnership()] }, updateFormula) +app.delete('/:id', { preHandler: [requireFormulaOwnership()] }, deleteFormula) +``` + +`requireFormulaOwnership()` 内部逻辑: +1. 查询 Formula.createdBy +2. 查询当前 User.role +3. admin → 放行 +4. engineer 且 createdBy === userId → 放行 +5. 否则 → throw ForbiddenError + +--- + +### 对比方案 + +| 方案 | 优势 | 劣势 | 结论 | +|------|------|------|------| +| **RBAC + Ownership(选)** | 角色简单(2种);所有权检查与业务语义对齐;中间件声明式清晰 | 需要为每个资源类型编写 ownership 中间件 | ✅ | +| 纯 RBAC(admin/formulator/viewer) | 无需资源级检查 | 无法表达"只能编辑自己的配方"(需要更多角色或 ABAC 条件) | ❌ | +| ABAC(属性动态判断) | 最灵活 | Admin UI 复杂;规则定义成本高;当前 2 角色场景下过度 | ❌ | +| 无授权(仅认证) | 最简单 | 任何认证用户可改 AI Key、删他人配方,不可接受 | ❌ | + +--- + +## 认证机制 + +| 组件 | 实现 | +|------|------| +| Token 格式 | JWT HS256(HMAC-SHA256) | +| 签名密钥 | `JWT_SECRET` 环境变量 | +| 有效期 | 24 小时 | +| 有效载荷 | `{ userId, exp }` | +| 密码哈希 | scrypt(salt 16 字节,输出 64 字节,hex 编码) | +| 密码比对 | `timingSafeEqual`(防时序攻击) | + +--- + +## 后果 + +- 新增资源类型(如以后有独立的内容管理模块)需实现对应的 ownership 中间件 +- 认证中间件通过 `app.ts` 全局 `preHandler` 注册,公开路由通过 URL 前缀白名单跳过 +- 测试环境使用 `buildApp({ skipAuth: true })` 绕过认证,`request.userId` 默认为 `'system'` +- 角色变更需修改 User.role 字段(当前通过数据库直改,未来需管理 UI) diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..0da1dd4 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,1076 @@ +# 配方研发智能平台 — API 参考文档 + +> 版本 v0.1.0 | 更新时间 2026-05-21 +> 后端框架 Fastify 5 + TypeScript | 数据库 PostgreSQL (pgvector) | AI 多 Provider 适配 + +--- + +## 目录 + +- [1. 通用约定](#1-通用约定) +- [2. 认证系统](#2-认证系统-auth) +- [3. 成分目录](#3-成分目录-ingredients) +- [4. 配方记录](#4-配方记录-formulas) +- [5. 颜色引擎](#5-颜色引擎-color) +- [6. AI 配方推演](#6-ai-配方推演-ai) +- [7. 项目管理](#7-项目管理-projects) +- [8. 配置管理](#8-配置管理-config) +- [9. 数据模型](#9-数据模型) +- [10. AI 模板说明](#10-ai-模板说明) + +--- + +## 1. 通用约定 + +### 1.1 域名与端口 + +| 环境 | 前端 | 后端 | +|------|------|------| +| 开发 | `http://localhost:5173` | `http://localhost:3001` | + +前端 dev server 自动代理 `/api` 到后端。 + +### 1.2 认证方式 + +所有带 `🔒` 标记的接口需在请求头携带 JWT Token。认证中间件在 `src/app.ts` 中以全局 `preHandler` 方式执行。 + +``` +Authorization: Bearer +``` + +**公开接口**(无需 Token): + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/health` | 健康检查 | +| POST | `/api/auth/register` | 注册 | +| POST | `/api/auth/login` | 登录 | + +### 1.3 统一响应信封 + +```json +{ + "data": {}, + "pagination": { "page": 1, "limit": 20, "total": 100, "totalPages": 5 } +} +``` + +列表接口包含 `pagination`,单条查询仅含 `data`。`DELETE` 返回 `204 No Content`。 + +### 1.4 统一错误信封 + +```json +{ "error": "错误描述", "statusCode": 400 } +``` + +| 状态码 | 含义 | 触发条件 | +|--------|------|----------| +| 400 | 参数校验失败 | Zod schema 校验不通过 | +| 401 | 未认证 | Token 缺失/格式错误/过期/用户不存在 | +| 404 | 资源不存在 | 记录未找到 | +| 409 | 冲突 | 如删除被配方引用的成分 | +| 500 | 服务器错误 | 生产环境仅返回 `Internal Server Error` | + +### 1.5 输入校验 + +所有请求体/查询参数通过 Zod schema 校验(定义于 `src/lib/validation.ts`),校验失败时返回 400 并附带具体字段错误信息,例如: + +```json +{ "error": "name: 配方名称不能为空; phases: 至少需要一个相" } +``` + +--- + +## 2. 认证系统 (auth) + +**路由前缀** `/api/auth` +**源文件** `src/routes/auth.ts` + +JWT 采用 HMAC-SHA256 (HS256) 签名,密钥读取自环境变量 `JWT_SECRET`,有效期 24 小时。密码哈希采用 `scrypt`(异步,salt 长度 16 字节,输出 64 字节,格式 `:`)。 + +### 2.1 POST `/api/auth/register` — 注册 + +**请求体** + +| 字段 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------| +| username | string | 是 | — | 唯一用户名 | +| password | string | 是 | ≥4 位 | 明文密码,服务端哈希存储 | + +**响应** `201` + +```json +{ + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "username": "wang", + "role": "engineer", + "token": "eyJhbGciOiJIUzI1NiIs..." + } +} +``` + +**错误** + +| 状态码 | 错误信息 | 说明 | +|--------|----------|------| +| 400 | `用户名和密码为必填项` | 缺少字段 | +| 400 | `密码至少4位` | 密码过短 | +| 409 | `用户名已存在` | username 重复 | + +### 2.2 POST `/api/auth/login` — 登录 + +**请求体** 同注册,password ≥1 位即可。 + +**响应** `200` — body 结构同注册返回。 + +**错误** + +| 状态码 | 错误信息 | +|--------|----------| +| 400 | `用户名不能为空` / `密码不能为空` | +| 401 | `用户名或密码错误` | + +### 2.3 GET `/api/auth/me` — 当前用户信息 + +``` +Authorization: Bearer +``` + +**响应** `200` + +```json +{ + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "username": "wang", + "role": "engineer" + } +} +``` + +**错误** `401` — Token 无效或用户已删除。 + +--- + +## 3. 成分目录 (ingredients) `🔒` + +**路由前缀** `/api/ingredients` +**源文件** `src/routes/ingredients.ts` → `src/services/ingredientService.ts` + +### 3.1 功能分类枚举 + +``` +emulsifier — 乳化剂 +humectant — 保湿剂 +thickener — 增稠剂 +preservative — 防腐剂 +antioxidant — 抗氧化剂 +fragrance — 香精 +colorant — 着色剂 +ph_adjuster — pH 调节剂 +sunscreen — 防晒剂 +surfactant — 表面活性剂 +emollient — 润肤剂 +other — 其他 +``` + +### 3.2 GET `/?page=&limit=&search=&category=` — 成分列表 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| page | int | 1 | 页码 | +| limit | int | 20 | 每页数量(最大 100) | +| search | string | — | 模糊匹配 INCI 名称和中文名(case-insensitive) | +| category | enum | — | 功能分类筛选 | + +**请求示例** + +``` +GET /api/ingredients?search=甘油&category=humectant&page=1&limit=10 +``` + +**响应** `200` + +```json +{ + "data": [ + { + "id": "uuid", + "inciName": "Glycerin", + "chineseName": "甘油", + "functionCategory": "humectant", + "supplier": "BASF", + "unit": "kg", + "unitPrice": 12.5, + "description": "天然保湿因子", + "createdAt": "2026-05-20T10:00:00.000Z" + } + ], + "pagination": { + "page": 1, + "limit": 10, + "total": 42, + "totalPages": 5 + } +} +``` + +### 3.3 GET `/:id` — 成分详情 + +**响应** `200` `{ "data": { /* 同上单条 */ } }` +**错误** `404` — `{ "error": "成分不存在" }` + +### 3.4 POST `/` — 创建成分 + +**请求体** + +| 字段 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------| +| inciName | string | 是 | — | INCI 国际命名 | +| chineseName | string | 是 | — | 中文常用名 | +| functionCategory | enum | 是 | 见 3.1 | 功能分类 | +| supplier | string | 否 | — | 供应商 | +| unit | string | 否 | 默认 `"kg"` | 计量单位 | +| unitPrice | number | 否 | ≥0 | 单价(元/单位) | +| description | string | 否 | — | 备注描述 | + +```json +{ + "inciName": "Niacinamide", + "chineseName": "烟酰胺", + "functionCategory": "humectant", + "supplier": "DSM", + "unit": "kg", + "unitPrice": 180.0, + "description": "维生素 B3,美白活性物" +} +``` + +**响应** `201` `{ "data": { /* 创建后的成分对象 */ } }` + +### 3.5 PUT `/:id` — 更新成分 + +**请求体** — 以上字段均可选(partial update)。 + +```json +{ "supplier": "新供应商", "unitPrice": 150.0 } +``` + +**响应** `200` `{ "data": { /* 更新后的成分 */ } }` +**错误** `404` — 成分不存在 + +### 3.6 DELETE `/:id` — 删除成分 + +**响应** `204 No Content` +**错误** `404` — 成分不存在 +**错误** `409` — `{ "error": "该成分已被配方引用,无法删除", "usageCount": 3 }` + +> 删除前检查 `FormulaIngredient` 表引用计数,被引用则拒绝删除。 + +--- + +## 4. 配方记录 (formulas) `🔒` + +**路由前缀** `/api/formulas` +**源文件** `src/routes/formulas.ts` → `src/services/formulaService.ts` + +### 4.1 配方核心概念 + +- **配方** (Formula) — 含名称、描述、所属项目、当前版本号 +- **相** (Phase) — 工艺阶段分组(如水相、油相、后添加相),有排序 +- **成分关联** (FormulaIngredient) — 在某个版本的某个相中,含比例和工艺备注 +- **版本** (FormulaVersion) — 每次修改成分后自动生成新版本 + +### 4.2 GET `/?page=&limit=&search=&sortBy=&sortOrder=` — 配方列表 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| page | int | 1 | 页码 | +| limit | int | 20 | 每页数量(≤100) | +| search | string | — | 名称/描述模糊搜索 | +| sortBy | enum | `updatedAt` | `createdAt` / `updatedAt` / `name` | +| sortOrder | enum | `desc` | `asc` / `desc` | + +**响应** `200` + +```json +{ + "data": [ + { + "id": "uuid", + "name": "保湿精华液", + "description": "清爽型保湿配方", + "currentVersion": 2, + "createdBy": "uuid", + "createdAt": "2026-05-20T10:00:00.000Z", + "updatedAt": "2026-05-21T08:30:00.000Z", + "project": { + "id": "uuid", + "name": "夏季新品" + } + } + ], + "pagination": { "page": 1, "limit": 20, "total": 50, "totalPages": 3 } +} +``` + +### 4.3 GET `/:id` — 配方详情 + +**响应** `200` + +```json +{ + "data": { + "id": "uuid", + "name": "保湿精华液", + "description": "清爽型保湿配方", + "currentVersion": 2, + "createdBy": "uuid", + "createdAt": "2026-05-20T10:00:00.000Z", + "updatedAt": "2026-05-21T08:30:00.000Z", + "project": { "id": "uuid", "name": "夏季新品" }, + "versions": [ + { + "id": "uuid", + "versionNumber": 2, + "description": "版本 v2", + "snapshotData": { "phases": [...] }, + "createdBy": "uuid", + "createdAt": "2026-05-21T08:30:00.000Z", + "phases": [ + { + "id": "uuid", + "name": "水相", + "sortOrder": 0, + "ingredients": [ + { + "id": "uuid", + "ingredientId": "uuid", + "percentage": 75.5, + "processNotes": "预先加热至 75°C", + "ingredient": { + "id": "uuid", + "inciName": "Aqua", + "chineseName": "水" + } + } + ] + }, + { + "id": "uuid", + "name": "后添加相", + "sortOrder": 1, + "ingredients": [ + { + "id": "uuid", + "ingredientId": "uuid", + "percentage": 24.5, + "processNotes": "降温至 40°C 后加入", + "ingredient": { + "id": "uuid", + "inciName": "Niacinamide", + "chineseName": "烟酰胺" + } + } + ] + } + ] + } + ] + } +} +``` + +> 仅返回最新版本(`take: 1, orderBy versionNumber desc`),历史版本见 [版本历史接口](#47-版本历史--版本对比暂未暴露为独立-api)。 + +### 4.4 POST `/` — 创建配方 + +**请求体** + +```json +{ + "name": "保湿精华液", + "description": "夏季保湿配方 v1", + "projectId": "uuid或null", + "phases": [ + { + "name": "水相", + "sortOrder": 0, + "ingredients": [ + { + "ingredientId": "uuid", + "percentage": 80.0, + "processNotes": "75°C 加热搅拌" + } + ] + }, + { + "name": "后添加相", + "sortOrder": 1, + "ingredients": [ + { + "ingredientId": "uuid", + "percentage": 20.0, + "processNotes": "40°C 加入" + } + ] + } + ] +} +``` + +**校验规则** + +- `name` 必填,`phases` 必填且至少 1 个相 +- 每个相至少 1 个成分 +- 每个成分的 `percentage` 范围 (0, 100] +- **所有成分百分比总和必须在 99.5% – 100.5% 之间** + +**响应** `201` `{ "data": { /* 完整 Formula 对象(含新创建的版本和关联) */ } }` + +> 内部执行事务:创建 Formula → 创建 FormulaVersion(v1) → 创建 Phase[] → 创建 FormulaIngredient[] → 回查并返回完整对象。 + +### 4.5 PUT `/:id` — 更新配方元信息 + +**请求体** + +```json +{ "name": "新名称", "description": "新描述" } +``` + +> 仅更新 name/description,不涉及版本变更。 + +**响应** `200` `{ "data": { /* 更新后的 Formula */ } }` +**错误** `404` — 配方不存在 + +### 4.6 PUT `/:id/composition` — 更新配方成分(创建新版本) + +**请求体** — 同创建配方的 `phases` 结构: + +```json +{ + "phases": [ + { + "name": "水相", + "sortOrder": 0, + "ingredients": [ + { "ingredientId": "uuid", "percentage": 70.0 } + ] + } + ] +} +``` + +> 百分比验证规则同创建接口。执行事务:创建新版本号 → 创建 Phase[] → 创建 FormulaIngredient[] → 更新 Formula.currentVersion。 + +**响应** `200` `{ "data": { /* 含最新版本信息的完整 Formula */ } }` +**错误** `400` — 百分比校验失败 +**错误** `404` — 配方不存在 + +### 4.7 版本历史 & 版本对比 + +> 前端路由中包含 `/formulas/:id/history` 和 `/formulas/:id/compare` 页面,对应的后端 API 尚未实现为独立端点。当前可通过配方详情接口的 `versions` 字段获取最新版本信息。 + +### 4.8 DELETE `/:id` — 删除配方 + +**响应** `204 No Content` + +> 级联删除逻辑(事务内顺序执行): +> 1. 查询该配方所有 FormulaVersion +> 2. 删除各版本下的 Phase 记录 +> 3. 删除各版本下的 FormulaIngredient 记录 +> 4. 删除所有 FormulaVersion +> 5. 删除 Formula + +**错误** `404` — 配方不存在 + +--- + +## 5. 颜色引擎 (color) `🔒` + +**路由前缀** `/api/color` +**源文件** `src/routes/color.ts` + +使用 CIELAB 颜色空间作为内部标准,色差计算采用欧几里得距离作为初步筛选,CIEDE2000 作为精确对比(前端 colorjs.io)。 + +### 5.1 POST `/recommend` — AI 配色推荐 + +**请求体** + +```json +{ + "targetLab": { + "L": 65.0, + "a": 15.0, + "b": -8.0 + } +} +``` + +**处理流程** +1. 查询所有已有 ColorFormula 记录 +2. 按欧几里得距离(Lab 空间中)排序取 Top 5 +3. 调用 AI `recommendColorants` 生成色浆组合推荐 + +**响应** `200` + +```json +{ + "recommendations": [ + { + "colorants": [ + { "name": "氧化铁红", "ratio": 0.35 }, + { "name": "二氧化钛", "ratio": 0.5 }, + { "name": "氧化铁黄", "ratio": 0.15 } + ], + "predictedDeltaE": 1.2, + "confidence": 0.85 + } + ], + "matchedFormulas": [ + { "id": "uuid", "name": "珊瑚粉底液 #3", "deltaE": 0.8 } + ] +} +``` + +### 5.2 GET `/formulas/match?L=&a=&b=&limit=` — 颜色配方匹配 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| L | number | 是 | — | CIELAB L* (0–100) | +| a | number | 是 | — | CIELAB a* | +| b | number | 是 | — | CIELAB b* | +| limit | int | 否 | 5 | 返回数量(≤20) | + +**响应** `200` + +```json +{ + "data": [ + { + "id": "uuid", + "name": "玫瑰豆沙色", + "targetLab": { "L": 55, "a": 25, "b": 5 }, + "deltaE": 0.5, + "colorantComposition": [...], + "distance": 1.23 + } + ] +} +``` + +### 5.3 POST `/formulas` — 保存颜色配方 + +**请求体** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| name | string | 否 | 默认 `"未命名颜色配方"` | +| targetLab | object | 是 | `{ L: 0-100, a: number, b: number }` | +| actualLab | object | 否 | 实际测得 Lab | +| deltaE | number | 否 | ΔE(≥0) | +| colorantComposition | any | 否 | 色浆组合(自由格式 JSON) | +| formulaId | uuid | 否 | 关联的配方 ID | + +**响应** `201` `{ "data": { /* ColorFormula 对象 */ } }` + +--- + +## 6. AI 配方推演 (ai) `🔒` + +**路由前缀** `/api/ai` +**源文件** `src/routes/ai.ts` → `src/services/ai/index.ts` `src/services/ai/templates/` + +所有 AI 能力通过 LLM API 实现(OpenAI / DeepSeek),支持 Mock 模式(返回预设 JSON)。提示词模板定义在 `src/services/ai/templates/index.ts`。 + +### 6.1 POST `/predict-formula` — 预测配方指标(SSE 流) + +**请求体** + +```json +{ + "ingredients": [ + { "name": "甘油", "percentage": 5, "category": "humectant" }, + { "name": "卡波姆", "percentage": 0.5, "category": "thickener" } + ] +} +``` + +> 成分列表至少 1 个。 + +**响应** `200` `Content-Type: text/event-stream` + +``` +data: {"type":"result","content":"{\"sensoryIndex\":{\"spreadability\":78,...},...}"} +``` + +`content` 是 JSON 字符串,解析后格式: + +```json +{ + "sensoryIndex": { + "spreadability": 78, + "absorption": 82, + "stickiness": 25, + "overall": 75 + }, + "stabilityScore": 85, + "costEstimate": 45.5, + "confidence": 0.85, + "reasoning": "甘油提供基础保湿,卡波姆增加稠度..." +} +``` + +| 字段 | 范围 | 说明 | +|------|------|------| +| sensoryIndex.spreadability | 0–100 | 铺展性 | +| sensoryIndex.absorption | 0–100 | 吸收速度 | +| sensoryIndex.stickiness | 0–100 | 黏腻度 | +| sensoryIndex.overall | 0–100 | 综合肤感 | +| stabilityScore | 0–100 | 稳定性评分 | +| costEstimate | 数值 | 估算单位成本(元/kg) | +| confidence | 0–1 | 预测置信度 | + +**错误流** `data: {"type":"error","content":"..."}` + +### 6.2 POST `/explore-formula` — 配方推演(SSE 流) + +**请求体** — 至少设置一个约束条件(`costLimit`、`targetMetrics` 或 `excludeIngredients`) + +```json +{ + "baseFormulaName": "原保湿精华", + "baseIngredients": [ + { "name": "甘油", "percentage": 5 } + ], + "costLimit": 50, + "keepIngredients": ["甘油", "烟酰胺"], + "excludeIngredients": ["酒精", "香精"], + "targetMetrics": { + "stability": 85, + "sensory": 80 + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| baseFormulaName | string? | 基础配方名称 | +| baseIngredients | array? | 基础成分及比例 | +| costLimit | number? | 成本上限(元/kg) | +| keepIngredients | string[]? | 必须保留的成分名 | +| excludeIngredients | string[]? | 必须排除的成分名 | +| targetMetrics | object? | 目标指标(自由 key/value) | + +**响应** `200` `Content-Type: text/event-stream` + +``` +data: {"type":"option","option":{...}} +data: {"type":"option","option":{...}} +data: {"type":"done"} +``` + +每个 `option` 对象: + +```json +{ + "name": "降本方案 A", + "changes": [ + { + "action": "reduce", + "ingredient": "烟酰胺", + "oldPercentage": 5, + "newPercentage": 3 + }, + { + "action": "add", + "ingredient": "丁二醇", + "oldPercentage": null, + "newPercentage": 2 + } + ], + "predictedMetrics": { + "costEstimate": 42.0, + "stabilityScore": 82 + }, + "reasoning": "用丁二醇替代部分烟酰胺可降低成本同时维持保湿效果" +} +``` + +### 6.3 POST `/extract-formula` — AI 提取配方文本 + +**请求体** + +```json +{ + "text": "水相:去离子水(75%)、甘油(5%)、丁二醇(3%),加热至75度。\n油相:角鲨烷(10%)、乳化蜡(5%),加热至80度。\n后添加:防腐剂(1%)、香精(1%),降温至40度加入。" +} +``` + +**响应** `200` + +```json +{ + "data": [ + { + "inciName": "Aqua", + "chineseName": "去离子水", + "percentage": 75, + "phase": "水相", + "processNotes": "加热至75度" + }, + { + "inciName": "Glycerin", + "chineseName": "甘油", + "percentage": 5, + "phase": "水相", + "processNotes": "" + } + ] +} +``` + +**错误** `500` — `{ "error": "AI 提取失败,请重试" }` + +### 6.4 GET `/search?q=` — AI 自然语言搜索 + +| 参数 | 类型 | 说明 | +|------|------|------| +| q | string(必填) | 自然语言搜索词 | + +``` +GET /api/ai/search?q=适合敏感肌的保湿配方不含酒精 +``` + +**处理流程** +1. AI 解析自然语言 → 提取关键词和筛选条件 +2. 用关键词执行 PostgreSQL ILIKE 搜索 +3. AI 不可用时降级为直接关键词搜索 + +**响应** `200` + +```json +{ + "data": [ + { + "id": "uuid", + "name": "敏感肌保湿乳", + "description": "无酒精、无香精", + "currentVersion": 1, + "project": { "name": "敏感肌系列" } + } + ], + "keywords": ["敏感肌", "保湿", "无酒精"] +} +``` + +--- + +## 7. 项目管理 (projects) `🔒` + +**路由前缀** `/api/projects` +**源文件** `src/routes/projects.ts` → `src/services/projectService.ts` + +### 7.1 GET `/` — 项目列表 + +**响应** `200` + +```json +{ + "data": [ + { + "id": "uuid", + "name": "夏季新品", + "description": "2026 夏季产品线", + "createdBy": "uuid", + "createdAt": "2026-05-20T10:00:00.000Z", + "_count": { "formulas": 5 } + } + ] +} +``` + +### 7.2 POST `/` — 创建项目 + +**请求体** + +```json +{ "name": "夏季新品", "description": "2026 夏季产品线" } +``` + +**响应** `201` `{ "data": { /* Project 对象 */ } }` + +### 7.3 PUT `/:id` — 更新项目 + +**请求体** + +```json +{ "name": "2026 秋季新品" } +``` + +**响应** `200` `{ "data": { /* 更新后的 Project */ } }` + +### 7.4 DELETE `/:id` — 删除项目 + +**响应** `204 No Content` + +--- + +## 8. 配置管理 (config) `🔒` + +**路由前缀** `/api/config` +**源文件** `src/routes/config.ts` → `src/lib/configStore.ts` + +API Key 存储于服务器端 `runtime/config.json` 文件,不通过客户端 localStorage 传输。服务启动时自动加载,运行时可通过接口更新。 + +### 8.1 GET `/` — 获取配置状态 + +**响应** `200` + +```json +{ + "aiMock": "true", + "hasOpenAI": true, + "hasDeepseek": false +} +``` + +> 不返回 API Key 实际值,仅返回是否已配置。 + +### 8.2 PUT `/` — 更新配置 + +**请求体** + +```json +{ + "openaiKey": "sk-...", + "deepseekKey": "sk-...", + "openaiBaseUrl": "https://api.openai.com/v1", + "deepseekBaseUrl": "https://api.deepseek.com/v1", + "aiMock": "false" +} +``` + +> 所有字段均可选。更新后自动 re-init AI Provider 并清空缓存。 + +**响应** `200` `{ "ok": true }` + +### 8.3 POST `/test` — 测试 AI Provider 连接 + +**请求体** + +```json +{ "provider": "openai" } +``` + +`provider` 取值:`"openai"` | `"deepseek"` + +**响应** `200` + +```json +{ "ok": true, "model": "gpt-4o" } +``` + +**错误** + +```json +{ "ok": false, "error": "Provider \"openai\" 未配置" } +``` + +--- + +## 9. 数据模型 + +### 9.1 实体关系图 + +``` +User ──1:N──→ Formula ──1:N──→ FormulaVersion ──1:N──→ Phase + │ │ │ + │ │ 1:N │ + │ │ FormulaIngredient ──N:1──→ Ingredient + │ │ + │ 1:N │ + ├──────→ ColorFormula + │ + └──────→ Project ──1:N──→ Formula +``` + +### 9.2 Prisma Schema 摘要 + +**User** (`users`) +- `id` UUID PK +- `username` 唯一 +- `passwordHash` — scrypt(salt:16, output:64) hex +- `role` — `engineer` / `admin` +- `createdAt` + +**Project** (`projects`) +- `id` UUID PK +- `name` String +- `description` String? +- `createdBy` → User +- `createdAt` + +**Formula** (`formulas`) +- `id` UUID PK +- `name` String +- `description` String? +- `projectId` → Project? +- `currentVersion` Int (default 1) +- `createdBy` → User +- `createdAt` / `updatedAt` +- `embedding` vector(1536)? — pgvector 向量,用于语义搜索 + +**FormulaVersion** (`formula_versions`) +- `id` UUID PK +- `formulaId` → Formula +- `versionNumber` Int — (formulaId, versionNumber) UNIQUE +- `description` String? +- `snapshotData` JSON — 版本快照 +- `createdBy` → User +- `createdAt` + +**Phase** (`phases`) +- `id` UUID PK +- `formulaId` → FormulaVersion +- `name` String +- `sortOrder` Int + +**FormulaIngredient** (`formula_ingredients`) +- `id` UUID PK +- `formulaVersionId` → FormulaVersion +- `phaseId` → Phase +- `ingredientId` → Ingredient +- `percentage` Float +- `processNotes` String? + +**Ingredient** (`ingredients`) +- `id` UUID PK +- `inciName` String +- `chineseName` String +- `functionCategory` enum — 见 3.1 +- `supplier` String? +- `unit` String (default "kg") +- `unitPrice` Float? +- `description` String? +- `createdAt` + +**ColorFormula** (`color_formulas`) +- `id` UUID PK +- `name` String +- `targetLab` JSON — { L, a, b } +- `actualLab` JSON? — { L, a, b } +- `deltaE` Float? +- `colorantComposition` JSON? +- `formulaId` → Formula? +- `createdBy` → User +- `createdAt` + +**AIAuditLog** (`ai_audit_logs`) +- `id` UUID PK +- `capability` String — 调用的 AI 能力名 +- `modelName` String — 使用的模型 +- `promptHash` String — 提示词哈希 +- `tokensUsed` Int? +- `durationMs` Int? +- `createdAt` + +--- + +## 10. AI 模板说明 + +所有 AI 调用使用预定义提示词模板(`src/services/ai/templates/index.ts`),JSON 模式约束输出格式。 + +### 10.1 模板-能力映射 + +| 模板函数 | 能力 | system prompt 角色 | +|----------|------|--------------------| +| `predictMetricsPrompt` | 预测配方指标 | 资深化妆品配方工程师 | +| `parseNLQueryPrompt` | 自然语言搜索 | 查询解析器 | +| `generateFormulaPrompt` | 配方推演 | 资深化妆品配方工程师 | +| `recommendColorantsPrompt` | 配色推荐 | 化妆品色彩专家 | +| `extractFormulaPrompt` | 配方文本提取 | 数据结构化提取 | + +### 10.2 AI Service 架构 + +``` +Route (ai.ts) + │ + ▼ +AIService (services/ai/index.ts) + ├── LRUCache (200 条, 带 TTL) + ├── RateLimiter (10 req/s) + ├── 重试 (最多 3 次) + ├── fallback → mock 模式 + └── audit log → ai_audit_logs 表 + │ + ▼ +AIProvider 接口 + ├── createOpenAIProvider (api.openai.com/v1) + └── createDeepSeekProvider (api.deepseek.com/v1) +``` + +**Mock 模式**:当 `AI_MOCK=true` 或 AI API 不可用时,返回预设数据(定义在 `MOCK_RESPONSES` 中)。 + +### 10.3 可用 Provider + +| Provider | 默认模型 | Base URL | +|----------|----------|----------| +| openai | gpt-4o | `https://api.openai.com/v1` | +| deepseek | deepseek-chat | `https://api.deepseek.com/v1` | + +> 通过 `PUT /api/config` 切换,需提供对应 API Key。可通过 `POST /api/config/test` 验证连接。 + +### 10.4 缓存策略 + +`LRUCache` 容量 200 条,Key 为 `sha256(prompt)`,默认 TTL 根据能力不同设置。`reload()` 时清空全部缓存。 + +--- + +## 附录 + +### A. 环境变量 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `PORT` | 3001 | 后端端口 | +| `DATABASE_URL` | `postgresql://colorfull:colorfull@localhost:5432/colorfull` | 数据库连接 | +| `JWT_SECRET` | `dev-secret-change-me` | JWT 签名密钥(生产环境必须覆盖) | +| `AI_DEFAULT_MODEL` | `deepseek-chat` | 默认 AI 模型 | +| `AI_MOCK` | `true` | 是否启用 Mock 模式 | +| `OPENAI_API_KEY` | — | OpenAI API Key | +| `DEEPSEEK_API_KEY` | — | DeepSeek API Key | +| `OPENAI_BASE_URL` | — | OpenAI 自定义 Base URL | +| `DEEPSEEK_BASE_URL` | — | DeepSeek 自定义 Base URL | + +### B. Docker 服务 + +```bash +# 启动 PostgreSQL (pgvector) + MinIO +docker compose up -d + +# 数据库连接信息 +Host: localhost:5432 +Database: colorfull +User: colorfull +Password: colorfull +``` + +### C. 项目脚本 + +```bash +# 后端 +cd backend +pnpm dev # 开发模式 (tsx watch) +pnpm test # 运行测试 +pnpm db:migrate # Prisma 迁移 +pnpm db:seed # 种子数据 + +# 前端 +cd frontend +pnpm dev # Vite 开发服务器 +pnpm build # 生产构建 +pnpm test # 运行测试 +pnpm lint # ESLint +``` diff --git a/docs/project-overview.md b/docs/project-overview.md new file mode 100644 index 0000000..8135af9 --- /dev/null +++ b/docs/project-overview.md @@ -0,0 +1,522 @@ +# 配方研发智能平台 — 项目全貌 + +> 最后更新 2026-05-21 | 版本 v0.1.0 | 企业级重构后 + +--- + +## 1. 项目是什么 + +AI 驱动的化妆品配方研发智能平台(纯 Web 端)。面向化妆品研发工程师,提供四大核心能力: + +| 能力 | 说明 | +|------|------| +| 颜色管理 | CIELAB 色空间 + Display P3 广色域渲染 + AI 配色推荐 | +| 可视化配方调整 | 拖拽交互 + 实时 AI 预测反馈 + ECharts 图表 | +| 配方记录管理 | 结构化存储 + 版本管理 + 自然语言搜索 | +| 配方推演 | 多方案并行优化 + Pareto 前沿 + 成本/功效约束 | + +--- + +## 2. 目录全貌 + +``` +color_full/ +│ +├── CONTEXT.md # 领域词汇表(纯业务术语) +├── README.md +├── AGENTS.md # Agent 配置 +│ +├── docs/ +│ ├── adr/ +│ │ ├── 0001-architecture-stack.md # 技术栈选型(React/Fastify/Prisma/...) +│ │ ├── 0002-ai-api-strategy.md # AI 外部 API 调用策略 +│ │ ├── 0003-four-layer-module-architecture.md # 后端四层模块化架构 +│ │ └── 0004-rbac-ownership-authorization.md # RBAC + 资源级 Ownership +│ ├── api-reference.md # API 接口文档(29 端点) +│ ├── project-overview.md # 本文件 +│ └── agents/ # Agent skills 配置 +│ +├── backend/ # 后端(Fastify + TypeScript) +│ ├── Dockerfile +│ ├── package.json +│ ├── tsconfig.json # strict + noUncheckedIndexedAccess +│ ├── vitest.config.ts +│ ├── eslint.config.js +│ ├── .prettierrc +│ ├── .env.example # 环境变量模板 +│ ├── runtime/ +│ │ └── config.json # 运行时 AI Key 持久化 +│ ├── scripts/ +│ │ └── generate-openapi.ts # OpenAPI spec 生成 +│ ├── prisma/ +│ │ ├── schema.prisma # 数据模型定义(10 个模型) +│ │ ├── seed.ts +│ │ └── migrations/ +│ └── src/ +│ ├── app.ts # ★ 应用入口:插件注册 + 全局中间件 +│ ├── server.ts # ★ 启动 + 优雅关闭 +│ ├── lib/ # 工具库 +│ │ ├── prisma.ts # Prisma 客户端 +│ │ ├── configStore.ts # 服务器端配置持久化 +│ │ ├── validate.ts # Zod → Fastify 校验桥接 +│ │ └── swagger.ts # Zod → JSON Schema 转换 +│ ├── shared/ # ★ 跨模块共享基础设施 +│ │ ├── errors/ +│ │ │ ├── app-error.ts # AppError 6 子类体系 +│ │ │ └── codes.ts # 30+ 错误码常量 +│ │ ├── logging/ +│ │ │ └── context.ts # AsyncLocalStorage 请求上下文 +│ │ ├── middleware/ +│ │ │ ├── rbac.ts # requireRole('admin') +│ │ │ └── ownership.ts # requireFormulaOwnership() +│ │ ├── metrics/ +│ │ │ └── metrics.ts # prom-client 5 指标 +│ │ └── audit/ +│ │ └── audit.service.ts # 结构化审计日志 +│ ├── services/ # 核心服务 +│ │ └── ai/ +│ │ ├── index.ts # ★ AIService(Provider 抽象 + 缓存 + 限流 + 回退) +│ │ ├── cache.ts # LRUCache +│ │ ├── rate-limiter.ts # Token Bucket +│ │ ├── audit.ts # AI 调用审计记录 +│ │ ├── providers/ +│ │ │ ├── types.ts # AIProvider 接口 +│ │ │ ├── openai.ts # OpenAI (GPT-4o) +│ │ │ └── deepseek.ts # DeepSeek (deepseek-chat) +│ │ └── templates/ +│ │ └── index.ts # 5 个 Prompt 模板 +│ ├── modules/ # ★ 业务模块(四层架构) +│ │ ├── auth/ # 认证(注册/登录/JWT) +│ │ ├── ingredients/ # 成分目录(route + service + repository + test) +│ │ ├── formulas/ # 配方记录(route + service + repository + test) +│ │ ├── color/ # 颜色引擎(推荐/匹配/保存) +│ │ ├── ai/ # AI 推演(预测/探索/提取/搜索, SSE 流) +│ │ ├── projects/ # 项目管理 +│ │ ├── config/ # 配置管理(AI Key/admin only) +│ │ └── health/ # 健康检查(live/ready + /metrics) +│ └── generated/ # Prisma 自动生成 +│ +├── frontend/ # 前端(React 19 + Vite 8 + TypeScript) +│ ├── Dockerfile +│ ├── package.json +│ ├── vite.config.ts +│ ├── vitest.config.ts +│ ├── eslint.config.js +│ ├── tsconfig.json / tsconfig.app.json / tsconfig.node.json +│ ├── index.html +│ ├── nginx.conf # 生产 Nginx 配置 +│ ├── scripts/ +│ │ └── generate-types.ts # OpenAPI → TypeScript 类型生成 +│ └── src/ +│ ├── main.tsx # ★ 入口:ToastProvider + QueryClient + Router +│ ├── App.tsx +│ ├── router.tsx # React Router v7 配置 +│ ├── index.css # Tailwind CSS 4 入口 +│ ├── lib/ +│ │ ├── api.ts # apiFetch 封装 + Auth header +│ │ ├── queryClient.ts # TanStack Query 配置 +│ │ └── color/ # 色彩科学工具 +│ │ ├── convert.ts # 色空间转换(Lab/Hex/RGB/LCH/P3) +│ │ ├── deltaE.ts # ΔE 2000/CMC/76 计算 +│ │ ├── types.ts # 颜色类型定义 +│ │ └── color.test.ts # 颜色工具测试 +│ ├── shared/ # ★ 共享 UI 基建 +│ │ ├── components/ +│ │ │ ├── Toast.tsx # Toast 通知系统(Provider + Hook) +│ │ │ ├── Skeleton.tsx # 骨架屏(单行/多行/页面级) +│ │ │ └── Alert.tsx # 警告提示(4 种变体) +│ │ └── services/ +│ │ └── api.ts # 统一 API 客户端(带 Auth Token) +│ ├── modules/ # 前端模块 +│ │ └── formulas/ +│ │ └── formulas.service.ts # 配方 Service 层(TanStack Query 就绪) +│ ├── pages/ # 页面组件(14 个) +│ │ ├── DashboardPage.tsx # 仪表盘 +│ │ ├── FormulaListPage.tsx # 配方列表 +│ │ ├── FormulaDetailPage.tsx # 配方详情(含可视化编辑器) +│ │ ├── FormulaEditorPage.tsx # 配方编辑器 +│ │ ├── FormulaExplorerPage.tsx # 配方推演 +│ │ ├── VersionHistoryPage.tsx # 版本历史 +│ │ ├── VersionComparePage.tsx # 版本对比 +│ │ ├── ColorLabPage.tsx # 颜色实验室 +│ │ ├── IngredientsPage.tsx # 成分目录 +│ │ ├── ProjectsPage.tsx # 项目管理 +│ │ ├── SettingsPage.tsx # 设置(外观 + AI 配置) +│ │ ├── SearchPage.tsx # AI 搜索 +│ │ ├── LoginPage.tsx # 登录 +│ │ └── RegisterPage.tsx # 注册 +│ ├── components/ # 共享组件 +│ │ ├── AuthGuard.tsx # 路由守卫 +│ │ ├── ErrorBoundary.tsx # 错误边界 +│ │ ├── ColorWheel.tsx # 色轮 Canvas +│ │ ├── EyedropperPanel.tsx # 取色棒 +│ │ ├── ColorRecommendPanel.tsx # AI 配色推荐弹窗 +│ │ └── FormulaVisualEditor.tsx # 配方可视化编辑器 +│ ├── layouts/ +│ │ └── AppLayout.tsx # 主布局(侧栏 + 顶栏) +│ ├── hooks/ +│ │ └── useAIPredict.ts # AI 预测 Hook +│ └── stores/ +│ ├── authStore.ts # 认证状态(Zustand) +│ └── themeStore.ts # 主题状态(Zustand) +│ +├── docker/ +│ └── Dockerfile.pgvector # PostgreSQL + pgvector 镜像 +├── docker-compose.yml # 开发环境(PostgreSQL + MinIO[可选]) +├── docker-compose.prod.yml # 生产环境(Traefik + PostgreSQL + Backend + Frontend) +└── scripts/ + ├── backup-db.sh # pg_dump 备份脚本(7 天留存) + └── init-db.sh # 数据库初始化 +``` + +--- + +## 3. 架构全景 + +### 3.1 后端:四层模块化 + +``` +HTTP Request + │ + ▼ +┌──────────────────────────────────────────────┐ +│ app.ts (Fastify) │ +│ ├── Helmet + CORS + RateLimit │ +│ ├── global preHandler: JWT verify │ +│ ├── global setErrorHandler: AppError → HTTP │ +│ └── /docs (Swagger UI) + /api/* routes │ +└────────────┬─────────────────────────────────┘ + │ + ┌────────▼────────┐ + │ Route Layer │ ← 参数提取 (req → 纯数据) + Zod 校验 + │ *.route.ts │ preHandler: requireRole() / requireFormulaOwnership() + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Service Layer │ ← 纯业务逻辑 + 审计埋点 + 百分比验证 + │ *.service.ts │ 依赖 Repository + AuditService + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Repository │ ← Prisma 查询封装 + 事务管理 + │ Layer │ 每个模块独立 Repository + │ *.repository.ts│ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Prisma ORM │ + │ PostgreSQL │ + │ + pgvector │ + └─────────────────┘ +``` + +### 3.2 横切关注点注入矩阵 + +| 关注点 | 注入方式 | 注入位置 | +|--------|----------|----------| +| 请求 ID | `genReqId: () => randomUUID()` | app.ts 构造 | +| 认证 | `addHook('preHandler')` → verifyToken → set userId | app.ts 全局 | +| 授权 | `{ preHandler: [requireRole(), requireFormulaOwnership()] }` | Route 注册 | +| 输入校验 | `validateOrReply(zodSchema, data, reply)` | Route handler | +| 错误处理 | `setErrorHandler(error, request, reply)` → AppError 子类匹配 | app.ts 全局 | +| 结构化日志 | `request.log.child({ requestId })` | app.ts onRequest | +| 审计日志 | `auditService.log({ action, resource, userId })` | Service 层显式调 | +| API 文档 | `@fastify/swagger` + `routeSchema()` | Route 注册 | +| Prometheus | `app_errors_total.inc()` / `/api/metrics` 端点 | 全局 handler + health 模块 | +| HTTP 安全 | `@fastify/helmet` + `@fastify/rate-limit` | app.ts 插件注册 | + +### 3.3 前端:渐进式分层 + +``` +React Router + │ + ▼ +┌──────────────────────────┐ +│ Page 组件 │ ← UI 渲染 + 调用 Hooks +│ (pages/) │ +└────────────┬─────────────┘ + │ + ┌────────▼────────────┐ + │ Hooks │ ← useQuery / useMutation (TanStack Query) + │ + 页面级 State │ useReducer / useState + └────────┬────────────┘ + │ + ┌────────▼────────────┐ + │ Service 层 │ ← API 调用封装 (apiFetch) + │ (modules/*.service) │ 类型安全的请求/响应 + └────────┬────────────┘ + │ + ┌────────▼────────────┐ + │ 共享 UI 基建 │ + │ - ToastProvider │ + │ - Skeleton │ + │ - Alert │ + │ - ErrorBoundary │ + └──────────────────────┘ +``` + +"当前状态:后端四层已全部落地,前端仅 formulaService 完成模板,其余 page 仍使用 `apiFetch` 直调。这是待完成的迁移动脉。" + +--- + +## 4. 数据模型 + +``` +User ────1:N──→ Formula ────1:N──→ FormulaVersion ────1:N──→ Phase + │ │ │ │ + │ │ │ 1:N │ + │ │ │ FormulaIngredient ────N:1──→ Ingredient + │ │ │ + │ 1:N │ 1:N │ + ├─────→ ColorFormula Phase (via formulaId on Phase) + │ + └─────→ Project ────1:N──→ Formula +``` + +| 表 | 核心字段 | 说明 | +|----|----------|------| +| users | username(unique), passwordHash(scrypt), role(engineer/admin) | 用户 | +| projects | name, description, createdBy | 项目 | +| formulas | name, description, currentVersion, projectId, embedding(vector) | 配方 | +| formula_versions | formulaId, versionNumber(unique pair), snapshotData(JSON), createdBy | 版本快照 | +| phases | name, formulaId(→FormulaVersion), sortOrder | 工艺阶段 | +| formula_ingredients | formulaVersionId, phaseId, ingredientId, percentage, processNotes | 成分关联 | +| ingredients | inciName, chineseName, functionCategory(12枚举), supplier, unitPrice | 原料 | +| color_formulas | name, targetLab(JSON), actualLab(JSON), deltaE, colorantComposition(JSON) | 颜色配方 | +| ai_audit_logs | capability, modelName, promptHash, tokensUsed, durationMs | AI 调用审计 | + +--- + +## 5. 安全架构 + +### 5.1 认证流程 + +``` +POST /api/auth/register + 用户名 + 密码 → scrypt 异步哈希 → 存入 users.passwordHash + 返回 JWT Token (HS256, 24h) + +POST /api/auth/login + 用户名 + 密码 → scrypt 异步比对 (timingSafeEqual) + 返回 JWT Token + +每个业务请求: + Authorization: Bearer + → preHandler: 解码验证 → 查 DB 确认用户存在 → set request.userId +``` + +### 5.2 授权矩阵 + +| 操作 | admin | engineer | +|------|:-----:|:--------:| +| 管理 AI 配置 | ✓ | ✗ | +| 增删改成份目录 | ✓ | ✓ | +| 创建配方 | ✓ | ✓ | +| 编辑/删除**自己**的配方 | ✓ | ✓ | +| 编辑/删除**他人**的配方 | ✓ | ✗ | +| 颜色引擎 / AI 推演 | ✓ | ✓ | +| 管理项目 | ✓ | ✓ | + +### 5.3 安全层 + +| 层 | 工具 | 作用 | +|----|------|------| +| HTTP 头 | `@fastify/helmet` | CSP/HSTS/X-Frame 等安全头 | +| CORS | `@fastify/cors` | 仅允许 localhost:5173 | +| 速率限制 | `@fastify/rate-limit` | 全局 100 req/min | +| 密码 | scrypt(salt=16, output=64) + timingSafeEqual | 抗彩虹表 + 防时序攻击 | +| Token | JWT HMAC-SHA256 (HS256) | 正确实现,非 SHA256 裸哈希 | +| API Key | `runtime/config.json` 服务器端存储 | 不从 localStorage 传输 | + +--- + +## 6. 可观测性 + +### 6.1 健康探针 + +| 端点 | 用途 | 实现 | +|------|------|------| +| `/api/health` | 基础存活 | 返回 timestamp | +| `/api/health/live` | K8s liveness | `SELECT 1` 检测 DB 连接,失败 503 | +| `/api/health/ready` | K8s readiness | SIGTERM 后返回 503 | +| `/api/metrics` | Prometheus scrape | 5 个指标 | + +### 6.2 Prometheus 指标 + +| 指标 | 类型 | 标签 | +|------|------|------| +| `http_requests_total` | Counter | method, path, status | +| `http_request_duration_ms` | Histogram | method, path | +| `app_errors_total` | Counter | category, module, code | +| `ai_requests_total` | Counter | capability, provider, status | +| `ai_request_duration_ms` | Histogram | capability | + +### 6.3 日志 + +- **框架**: pino(结构化 JSON) +- **上下文**: 每个请求自动注入 `requestId` +- **审计**: `auditService.log()` → `{ audit: true, action, resource, resourceId, userId }` +- **格式**: 开发 `pino-pretty`,生产纯 JSON → Loki/ELK + +--- + +## 7. 部署架构 + +### 7.1 开发环境 + +```bash +docker compose up # PostgreSQL (pgvector) +pnpm dev # backend (3001) + frontend (5173) +``` + +MinIO 可选:`docker compose --profile full up` + +### 7.2 生产环境 + +``` +┌────────────────────────────────────────────┐ +│ Traefik (80/443) │ +│ ├── /api/* → backend:3001 │ +│ └── /* → frontend:80 │ +├────────────────────────────────────────────┤ +│ Backend (Fastify, port 3001) │ +│ ├── /api/* 业务路由 │ +│ ├── /docs Swagger UI │ +│ ├── /api/health/live liveness probe │ +│ ├── /api/health/ready readiness probe │ +│ └── /api/metrics Prometheus scrape │ +├────────────────────────────────────────────┤ +│ Frontend (Nginx, port 80) │ +│ ├── SPA static files │ +│ └── /api/* → backend proxy │ +├────────────────────────────────────────────┤ +│ PostgreSQL (pgvector) │ +└────────────────────────────────────────────┘ +``` + +```bash +docker compose -f docker-compose.prod.yml up -d +``` + +### 7.3 备份 + +```bash +# 手动备份 +./scripts/backup-db.sh + +# crontab 每日备份 +0 2 * * * /opt/colorfull/scripts/backup-db.sh >> /var/log/colorfull-backup.log 2>&1 +``` + +--- + +## 8. 质量保障 + +### 8.1 TypeScript 严格度 + +| 开关 | 后端 | 前端 | +|------|:----:|:----:| +| `strict: true` | ✓ | ✓ | +| `noUncheckedIndexedAccess` | ✓ | ✓ | +| `noUnusedLocals` | ✓ | ✓ | +| `noUnusedParameters` | ✓ | ✓ | +| `skipLibCheck` | ✓ | ✓ | + +### 8.2 代码规范 + +| 工具 | 后端 | 前端 | +|------|:----:|:----:| +| ESLint | ✓ (`typescript-eslint`) | ✓ | +| Prettier | ✓ (semi=false, singleQuote, trailingComma=all) | ✓ | +| 状态 | **lint 零错误** | — | + +### 8.3 测试 + +| 层 | 后端 | 前端 | +|----|------|------| +| 单元测试 | —(待补:Service 层) | `lib/color/color.test.ts`(色空间转换) | +| 集成测试 | `ingredients.test.ts` + `formulas.test.ts`(26 用例) | —(待补:React Testing Library) | +| E2E | —(待补:Playwright) | — | +| 运行 | `pnpm test` (vitest) | `pnpm test` (vitest) | + +### 8.4 命令速查 + +```bash +# 后端 +cd backend +pnpm dev # 启动开发服务器 (tsx watch) +pnpm build # TypeScript 编译 +pnpm test # 运行 26 个集成测试 +pnpm lint # ESLint 检查 +pnpm format # Prettier 格式化 +pnpm db:migrate # 数据库迁移 +pnpm db:seed # 种子数据 +pnpm api:gen # 生成 OpenAPI spec + +# 前端 +cd frontend +pnpm dev # 启动 Vite 开发服务器 +pnpm build # 生产构建 +pnpm lint # ESLint +pnpm api:gen # 从 OpenAPI spec 生成 TypeScript 类型 +``` + +--- + +## 9. API 速览 + +| 模块 | 端点 | 方法 | 认证 | 说明 | +|------|------|------|:----:|------| +| health | `/api/health` | GET | ✗ | 基础存活 | +| health | `/api/health/live` | GET | ✗ | DB 检查 | +| health | `/api/health/ready` | GET | ✗ | 就绪检查 | +| health | `/api/metrics` | GET | ✗ | Prometheus | +| auth | `/api/auth/register` | POST | ✗ | 注册 | +| auth | `/api/auth/login` | POST | ✗ | 登录 | +| auth | `/api/auth/me` | GET | ✓ | 当前用户 | +| ingredients | `/api/ingredients` | GET/POST | ✓ | 列表/创建 | +| ingredients | `/api/ingredients/:id` | GET/PUT/DEL | ✓ | 详情/更新/删除 | +| formulas | `/api/formulas` | GET/POST | ✓ | 列表/创建 | +| formulas | `/api/formulas/:id` | GET/PUT/DEL | ✓ | 详情/更新/删除 | +| formulas | `/api/formulas/:id/composition` | PUT | ✓ | 更新成分(新版本) | +| color | `/api/color/recommend` | POST | ✓ | AI 配色推荐 | +| color | `/api/color/formulas/match` | GET | ✓ | 颜色配方匹配 | +| color | `/api/color/formulas` | POST | ✓ | 保存颜色配方 | +| ai | `/api/ai/predict-formula` | POST | ✓ | 预测指标(SSE) | +| ai | `/api/ai/explore-formula` | POST | ✓ | 配方推演(SSE) | +| ai | `/api/ai/extract-formula` | POST | ✓ | 提取配方文本 | +| ai | `/api/ai/search` | GET | ✓ | NL 搜索 | +| projects | `/api/projects` | GET/POST | ✓ | 列表/创建 | +| projects | `/api/projects/:id` | PUT/DEL | ✓ | 更新/删除 | +| config | `/api/config` | GET/PUT | ✓ | 查看/更新配置(admin) | +| config | `/api/config/test` | POST | ✓ | 测试 AI 连接(admin) | + +--- + +## 10. 待完成清单 + +| 优先级 | 项目 | 状态 | +|--------|------|:----:| +| P1 | 前端页面逐批迁移到 Service 层 + TanStack Query | 仅 formulaService 完成 | +| P1 | 前端集成 shadcn/ui 组件(替换手动创建的 Toast/Skeleton/Alert) | Tailwind 4 兼容性问题待解决 | +| P2 | 后端 Service 层单元测试(mock Repository) | 待开始 | +| P2 | 后端 Testcontainers 集成测试框架 | 待开始 | +| P2 | Playwright E2E 关键流程测试 | 待开始 | +| P2 | Husky pre-commit hooks(后端 + 前端) | 待配置 | +| P3 | CI/CD 流水线(GitHub Actions / GitLab CI) | 待配置 | +| P3 | 前端 OpenAPI 类型生成流水线验证 | 脚本已就绪,待端到端跑通 | +| P3 | Admin 用户管理 UI | 直接操作 DB | + +--- + +## 11. 相关文档索引 + +| 文档 | 路径 | 内容 | +|------|------|------| +| 领域词汇 | `CONTEXT.md` | 纯业务术语定义 | +| 技术栈 ADR | `docs/adr/0001-architecture-stack.md` | 为什么选 React/Fastify/Prisma 等 | +| AI 策略 ADR | `docs/adr/0002-ai-api-strategy.md` | 为什么用外部 LLM API | +| 架构 ADR | `docs/adr/0003-four-layer-module-architecture.md` | 为什么四层模块化 | +| 授权 ADR | `docs/adr/0004-rbac-ownership-authorization.md` | 为什么 RBAC + Ownership | +| API 文档 | `docs/api-reference.md` | 29 个接口完整说明 | +| PRD | `.scratch/formula-rd-platform/PRD.md` | 产品需求 | diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..7d531a2 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +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 build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..b3acaa9 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Request-Id $request_id; + proxy_read_timeout 120s; + } +} diff --git a/frontend/package.json b/frontend/package.json index 1371136..6d2815b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,8 @@ "test": "vitest run", "test:watch": "vitest", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "api:gen": "tsx scripts/generate-types.ts" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -49,6 +50,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "openapi-typescript": "^7.13.0", "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.8.0", "typescript": "~6.0.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 2e62485..ee53457 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -55,10 +55,10 @@ importers: version: 7.9.0 echarts: specifier: ^6.0.0 - version: 6.0.0 + version: 6.1.0 echarts-for-react: specifier: ^3.0.6 - version: 3.0.6(echarts@6.0.0)(react@19.2.6) + version: 3.0.6(echarts@6.1.0)(react@19.2.6) lucide-react: specifier: ^1.16.0 version: 1.16.0(react@19.2.6) @@ -114,6 +114,9 @@ importers: globals: specifier: ^17.6.0 version: 17.6.0 + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@6.0.3) prettier: specifier: ^3.8.3 version: 3.8.3 @@ -720,6 +723,16 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.14': + resolution: {integrity: sha512-y+xFx+Zz54Xhr8jUdnLENYnt7Y7GEDL6Q03ga7rTtX8DVwefX9H+hQEPgJp1nda7vdH+wJ9/HBVvyfBuW9x6rA==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/binding-android-arm64@1.0.1': resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1075,9 +1088,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.2: + resolution: {integrity: sha512-JVzqkCNRT+VfqzzgPWDPnwvDheSAUdiMUn3NoLXpDJF5lRqeJqyC9iGsAxIOAW+mzIdq+uP1TvcX6bMtrH0agg==} + engines: {node: '>= 14'} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1089,6 +1113,9 @@ packages: ast-v8-to-istanbul@1.0.0: resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + balanced-match@1.0.0: + resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -1098,6 +1125,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -1114,10 +1144,16 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + colorjs.io@0.6.1: resolution: {integrity: sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==} @@ -1294,8 +1330,8 @@ packages: echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 react: ^15.0.0 || >=16.0.0 - echarts@6.0.0: - resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==} + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} electron-to-chromium@1.5.358: resolution: {integrity: sha512-EO7tKm3QxRqTs1lSuPXzl6yRAwznehp0AH9OoMOIC+4mQzTFday8FJCO5KU6J/TFSQXEOahNq4vTKpz1jmCVOA==} @@ -1445,6 +1481,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -1461,6 +1501,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} @@ -1492,12 +1536,20 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1509,6 +1561,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1624,6 +1679,10 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1641,6 +1700,12 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1653,6 +1718,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1671,6 +1740,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} @@ -1805,6 +1878,10 @@ packages: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -1858,6 +1935,10 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1900,6 +1981,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@4.39.1: + resolution: {integrity: sha512-uW9qzd66uyHYxwyVBYiwS4Oi0qZyUqwjU+Oevr6ZogYiXt99EOYtwvzMSLw1c3lYo2HzJsep/NB23iEVEgjG/w==} + engines: {node: '>=16'} + typescript-eslint@8.59.4: resolution: {integrity: sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1921,6 +2006,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -2045,6 +2133,13 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2058,8 +2153,8 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - zrender@6.0.0: - resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==} + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} zustand@5.0.13: resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} @@ -2102,7 +2197,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -2172,7 +2267,7 @@ snapshots: '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -2234,7 +2329,7 @@ snapshots: '@eslint/config-array@0.23.5': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -2708,6 +2803,29 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.14(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.1.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rolldown/binding-android-arm64@1.0.1': optional: true @@ -2890,7 +3008,7 @@ snapshots: '@typescript-eslint/types': 8.59.4 '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.59.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 10.4.0(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: @@ -2900,7 +3018,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) '@typescript-eslint/types': 8.59.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -2919,7 +3037,7 @@ snapshots: '@typescript-eslint/types': 8.59.4 '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) '@typescript-eslint/utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 10.4.0(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 @@ -2934,7 +3052,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) '@typescript-eslint/types': 8.59.4 '@typescript-eslint/visitor-keys': 8.59.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.5 semver: 7.8.0 tinyglobby: 0.2.16 @@ -3025,6 +3143,12 @@ snapshots: acorn@8.16.0: {} + agent-base@7.1.2(supports-color@10.2.2): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -3032,6 +3156,10 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-colors@4.1.3: {} + + argparse@2.0.1: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -3044,10 +3172,16 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + balanced-match@1.0.0: {} + balanced-match@4.0.4: {} baseline-browser-mapping@2.10.31: {} + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.0 + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -3064,8 +3198,12 @@ snapshots: chai@6.2.2: {} + change-case@5.4.4: {} + clsx@2.1.1: {} + colorette@1.4.0: {} + colorjs.io@0.6.1: {} commander@7.2.0: {} @@ -3234,9 +3372,11 @@ snapshots: d3-transition: 3.0.1(d3-selection@3.0.0) d3-zoom: 3.0.0 - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 deep-is@0.1.4: {} @@ -3248,17 +3388,17 @@ snapshots: detect-node-es@1.1.0: {} - echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.6): + echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.6): dependencies: - echarts: 6.0.0 + echarts: 6.1.0 fast-deep-equal: 3.1.3 react: 19.2.6 size-sensor: 1.0.3 - echarts@6.0.0: + echarts@6.1.0: dependencies: tslib: 2.3.0 - zrender: 6.0.0 + zrender: 6.1.0 electron-to-chromium@1.5.358: {} @@ -3313,7 +3453,7 @@ snapshots: '@types/estree': 1.0.9 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -3411,6 +3551,13 @@ snapshots: html-escaper@2.0.2: {} + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.2(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -3421,6 +3568,8 @@ snapshots: imurmurhash@0.1.4: {} + index-to-position@1.2.0: {} + internmap@2.0.3: {} is-extglob@2.1.1: {} @@ -3446,16 +3595,24 @@ snapshots: jiti@2.7.0: {} + js-levenshtein@1.1.6: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + jsesc@3.1.0: {} json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -3548,6 +3705,10 @@ snapshots: dependencies: brace-expansion: 5.0.6 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + ms@2.1.3: {} nanoid@3.3.12: {} @@ -3558,6 +3719,16 @@ snapshots: obug@2.1.1: {} + openapi-typescript@7.13.0(typescript@6.0.3): + dependencies: + '@redocly/openapi-core': 1.34.14(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 6.0.3 + yargs-parser: 21.1.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3575,6 +3746,12 @@ snapshots: dependencies: p-limit: 3.1.0 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + index-to-position: 1.2.0 + type-fest: 4.39.1 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -3585,6 +3762,8 @@ snapshots: picomatch@4.0.4: {} + pluralize@8.0.0: {} + postcss@8.5.14: dependencies: nanoid: 3.3.12 @@ -3653,6 +3832,8 @@ snapshots: react@19.2.6: {} + require-from-string@2.0.2: {} + robust-predicates@3.0.3: {} rolldown@1.0.1: @@ -3704,6 +3885,8 @@ snapshots: std-env@4.1.0: {} + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3735,6 +3918,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@4.39.1: {} + typescript-eslint@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) @@ -3756,6 +3941,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js-replace@1.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -3828,6 +4015,10 @@ snapshots: yallist@3.1.1: {} + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.4.3): @@ -3836,7 +4027,7 @@ snapshots: zod@4.4.3: {} - zrender@6.0.0: + zrender@6.1.0: dependencies: tslib: 2.3.0 diff --git a/frontend/scripts/generate-types.ts b/frontend/scripts/generate-types.ts new file mode 100644 index 0000000..870598e --- /dev/null +++ b/frontend/scripts/generate-types.ts @@ -0,0 +1,34 @@ +import { execSync } from 'child_process' +import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs' +import { join } from 'path' + +const ROOT = join(import.meta.dirname, '..') +const BACKEND = join(ROOT, '..', 'backend') +const SPEC_FILE = join(BACKEND, 'generated', 'openapi.json') +const OUT_DIR = join(ROOT, 'src', 'generated') +const OUT_FILE = join(OUT_DIR, 'api.ts') + +async function main() { + if (!existsSync(SPEC_FILE)) { + console.log('Generating OpenAPI spec from backend...') + execSync('pnpm api:gen', { cwd: BACKEND, stdio: 'inherit' }) + } + + console.log('Generating TypeScript types from OpenAPI spec...') + const spec = JSON.parse(readFileSync(SPEC_FILE, 'utf-8')) + + const openapiTS = await import('openapi-typescript') + const types = await openapiTS.default(new URL(`file://${SPEC_FILE}`), { + exportType: true, + }) + + if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true }) + writeFileSync(OUT_FILE, types) + + console.log(`Types written to src/generated/api.ts (${types.length} bytes)`) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/frontend/src/components/ColorRecommendPanel.tsx b/frontend/src/components/ColorRecommendPanel.tsx index 4f41cdf..9e59d3e 100644 --- a/frontend/src/components/ColorRecommendPanel.tsx +++ b/frontend/src/components/ColorRecommendPanel.tsx @@ -55,7 +55,7 @@ export default function ColorRecommendPanel({ currentLab, targetLab }: ColorReco - + AI 配色推荐 @@ -72,7 +72,7 @@ export default function ColorRecommendPanel({ currentLab, targetLab }: ColorReco ) : ( <> -
+
目标色:{targetLab ? `Lab(${targetLab.L.toFixed(0)},${targetLab.a.toFixed(0)},${targetLab.b.toFixed(0)})` : '当前色'}
@@ -108,7 +108,7 @@ export default function ColorRecommendPanel({ currentLab, targetLab }: ColorReco
) : matchedFormulas.length > 0 ? (
-

AI 未返回推荐,以下是历史匹配配方:

+

AI 未返回推荐,以下是历史匹配配方:

{matchedFormulas.map((f, i) => (
{f.name as string} @@ -121,7 +121,7 @@ export default function ColorRecommendPanel({ currentLab, targetLab }: ColorReco )}
-

皮肤预览

+

皮肤预览

{ if (this.state.hasError) { return (
-
+

出错了

-

{this.state.error?.message ?? '发生了意外错误'}

+

{this.state.error?.message ?? '发生了意外错误'}

{!isValid && total > 0 && ( )} @@ -199,7 +199,7 @@ export default function FormulaVisualEditor({ phases: initialPhases, onSave }: P {hasPrediction && (
-

肤感子维度

+

肤感子维度

-

稳定性评分

+

稳定性评分

-

配方结构

+

配方结构

{allIngredients.map((ing, i) => (
{phases.map((phase, pi) => (
-

{phase.name}

+

{phase.name}

{phase.ingredients.map((ing, ii) => { const flatIdx = phases.slice(0, pi).reduce((s, p) => s + p.ingredients.length, 0) + ii diff --git a/frontend/src/components/InitConfig.tsx b/frontend/src/components/InitConfig.tsx deleted file mode 100644 index 22766d7..0000000 --- a/frontend/src/components/InitConfig.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useEffect } from 'react' - -export default function InitConfig() { - useEffect(() => { - const openaiKey = localStorage.getItem('openai-key') - const deepseekKey = localStorage.getItem('deepseek-key') - const openaiBaseUrl = localStorage.getItem('openai-base-url') - const deepseekBaseUrl = localStorage.getItem('deepseek-base-url') - const aiMock = localStorage.getItem('ai-mock') ?? 'true' - - const body: Record = { aiMock } - if (openaiKey) body.openaiKey = openaiKey - if (deepseekKey) body.deepseekKey = deepseekKey - if (openaiBaseUrl) body.openaiBaseUrl = openaiBaseUrl - if (deepseekBaseUrl) body.deepseekBaseUrl = deepseekBaseUrl - - fetch('/api/config', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }).catch(() => {}) - }, []) - - return null -} diff --git a/frontend/src/index.css b/frontend/src/index.css index f1d8c73..d106f45 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1 +1,144 @@ @import "tailwindcss"; + +@theme { + --color-gray-50: #F5E6E6; + --color-gray-100: #F0E0E0; + --color-gray-200: #E8D0D0; + --color-gray-300: #D4B5B5; + --color-gray-400: #C0A0A0; + --color-gray-500: #999999; + --color-gray-600: #8C6C6C; + --color-gray-700: #6C5050; + --color-gray-800: #4C3434; + --color-gray-900: #333333; + --color-blue-50: #FDF5F0; + --color-blue-100: #FAF0E8; + --color-blue-500: #C9A96E; + --color-blue-600: #D4A5A5; + --color-blue-700: #C9A96E; +} + +.dark .bg-blue-600, +.dark .bg-blue-700:hover { + background-color: #9B8EC4 !important; + border-color: #9B8EC4 !important; + color: #FFFFFF !important; +} + +.dark .bg-blue-500 { + background-color: #4CB8A7 !important; +} + +.dark { + background-color: #1A1830 !important; +} + +.dark body { + background-color: #1A1830 !important; +} + +.dark .bg-gray-50, +.dark .bg-white, +.dark aside, +.dark header { + background-color: #282540 !important; +} + +.dark .dark\:bg-gray-900 { + background-color: #1A1830 !important; +} + +.dark .dark\:bg-gray-800 { + background-color: #2C2840 !important; +} + +.dark .dark\:bg-gray-700, +.dark .dark\:hover\:bg-gray-100:hover { + background-color: #3A3650 !important; +} + +.dark input, +.dark select { + background-color: #2C2840 !important; + border-color: #3A3650 !important; + color: #E8E4F0 !important; +} + +.dark .border-gray-200, +.dark .dark\:border-gray-700 { + border-color: #3A3650 !important; +} + +.dark .text-gray-900, +.dark .text-gray-800, +.dark .text-gray-700, +.dark h2, +.dark .font-bold, +.dark .font-semibold { + color: #E8E4F0 !important; +} + +.dark .text-gray-600, +.dark .dark\:text-gray-400 { + color: #C8C0D8 !important; +} + +.dark .text-gray-500 { + color: #A8A0C0 !important; +} + +.dark .text-gray-400, +.dark .dark\:text-gray-300 { + color: #9B8EC4 !important; +} + +.dark .text-blue-600 { + color: #9B8EC4 !important; +} + +.dark .text-blue-700 { + color: #4CB8A7 !important; +} + +.dark .dark\:bg-gray-50 { + background-color: #1A1830 !important; +} + +button.rounded-lg:not([class*="bg-blue"]), +button.rounded-md:not([class*="bg-blue"]), +.btn-ghost { + background-color: rgba(255,255,255,0.85) !important; +} + +button.rounded-lg:not([class*="bg-blue"]):hover, +button.rounded-md:not([class*="bg-blue"]):hover, +.btn-ghost:hover { + background-color: var(--color-gray-100) !important; +} + +.dark button.rounded-lg:not([class*="bg-blue"]), +.dark button.rounded-md:not([class*="bg-blue"]), +.dark .btn-ghost { + background-color: #2C2840 !important; + border-color: #3A3650 !important; + color: #E8E4F0 !important; +} + +.dark button.rounded-lg:not([class*="bg-blue"]):hover, +.dark button.rounded-md:not([class*="bg-blue"]):hover, +.dark .btn-ghost:hover { + background-color: #3A3650 !important; +} + +.bg-blue-600, +.bg-blue-700:hover { + background-color: #D4A5A5 !important; + border: 1px solid #D4A5A5; + color: #FFFFFF !important; +} + +.bg-blue-600:hover, +.bg-blue-700 { + background-color: #C9A96E !important; + border-color: #C9A96E !important; +} diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx index 50d86ad..ec525fd 100644 --- a/frontend/src/layouts/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout.tsx @@ -101,7 +101,7 @@ export default function AppLayout() {
@@ -112,7 +112,7 @@ export default function AppLayout() { type="text" placeholder="搜索配方...(支持自然语言)" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} onKeyDown={handleSearch} - className="w-full rounded-lg border border-gray-200 py-1.5 pl-8 pr-3 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800" + className="w-full rounded-lg border border-gray-200 py-1.5 pl-8 pr-3 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-700" />
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 02077e7..ec65fdc 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -7,8 +7,26 @@ export class ApiError extends Error { } } +function getAuthHeaders(): Record { + try { + const raw = localStorage.getItem('auth-storage') + if (!raw) return {} + const parsed = JSON.parse(raw) as { state?: { token?: string } } + const token = parsed?.state?.token + return token ? { Authorization: `Bearer ${token}` } : {} + } catch { + return {} + } +} + export async function apiFetch(url: string, options?: RequestInit): Promise { - const res = await fetch(url, options) + const headers = new Headers(options?.headers) + const authHeaders = getAuthHeaders() + for (const [key, value] of Object.entries(authHeaders)) { + headers.set(key, value) + } + + const res = await fetch(url, { ...options, headers }) if (res.status === 204) return undefined as T const text = await res.text() diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b6446c2..7f2eeee 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client' import { RouterProvider } from 'react-router-dom' import { QueryClientProvider } from '@tanstack/react-query' import { ErrorBoundary } from './components/ErrorBoundary' -import InitConfig from './components/InitConfig' +import { ToastProvider } from './shared/components/Toast' import { router } from './router' import { queryClient } from './lib/queryClient' import './index.css' @@ -11,10 +11,11 @@ import './index.css' createRoot(document.getElementById('root')!).render( - - - - + + + + + , ) diff --git a/frontend/src/modules/formulas/components/ParetoChart.tsx b/frontend/src/modules/formulas/components/ParetoChart.tsx new file mode 100644 index 0000000..41a1c30 --- /dev/null +++ b/frontend/src/modules/formulas/components/ParetoChart.tsx @@ -0,0 +1,81 @@ +import { useMemo } from 'react' +import ReactECharts from 'echarts-for-react' + +interface ParetoPoint { + name: string + costEstimate: number + index: number +} + +interface ParetoChartProps { + options: ParetoPoint[] +} + +export default function ParetoChart({ options }: ParetoChartProps) { + const chartData = useMemo(() => { + return options.map((opt, i) => ({ + value: [opt.costEstimate, options.length - i], + name: opt.name, + })) + }, [options]) + + const paretoIndices = useMemo(() => { + if (options.length < 2) return [] + const sorted = [...options].sort((a, b) => a.costEstimate - b.costEstimate) + return sorted.map(s => options.indexOf(s)) + }, [options]) + + const option = { + tooltip: { + trigger: 'item', + formatter: (params: { data: { value: number[]; name: string } }) => { + const [cost] = params.data.value + return `${params.data.name}
成本: ${cost} 元/kg` + }, + }, + grid: { left: 60, right: 30, top: 30, bottom: 50 }, + xAxis: { + name: '成本 (元/kg)', + nameLocation: 'center', + nameGap: 30, + type: 'value', + }, + yAxis: { + name: '方案序号', + type: 'value', + splitLine: { show: true }, + }, + series: [ + { + type: 'scatter', + data: chartData, + symbolSize: 14, + itemStyle: { + color: (params: { dataIndex: number }) => + paretoIndices.includes(params.dataIndex) ? '#ef4444' : '#3b82f6', + }, + label: { + show: true, + formatter: (params: { data: { name: string } }) => params.data.name, + position: 'right', + fontSize: 11, + }, + emphasis: { + itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.3)' }, + }, + }, + ], + } + + if (options.length === 0) return null + + return ( +
+

Pareto 前沿

+

+ 红色点表示 Pareto 最优方案(在成本和指标上都无法改进) +

+ +
+ ) +} diff --git a/frontend/src/modules/formulas/formulas.service.ts b/frontend/src/modules/formulas/formulas.service.ts new file mode 100644 index 0000000..4fe03ad --- /dev/null +++ b/frontend/src/modules/formulas/formulas.service.ts @@ -0,0 +1,108 @@ +import { apiFetch } from '@/shared/services/api' + +export interface FormulaListItem { + id: string + name: string + description: string | null + currentVersion: number + createdBy: string + createdAt: string + updatedAt: string + project: { id: string; name: string } | null +} + +export interface FormulaDetail { + id: string + name: string + description: string | null + currentVersion: number + createdBy: string + createdAt: string + updatedAt: string + project: { id: string; name: string } | null + versions: FormulaVersion[] +} + +export interface FormulaVersion { + id: string + versionNumber: number + description: string | null + snapshotData: unknown + createdBy: string + createdAt: string + phases: Phase[] +} + +export interface Phase { + id: string + name: string + sortOrder: number + ingredients: FormulaIngredient[] +} + +export interface FormulaIngredient { + id: string + ingredientId: string + percentage: number + processNotes: string | null + ingredient: { id: string; inciName: string; chineseName: string } +} + +export interface PaginatedResponse { + data: T[] + pagination: { page: number; limit: number; total: number; totalPages: number } +} + +export interface CreateFormulaInput { + name: string + description?: string + projectId?: string + phases: { + name: string + sortOrder?: number + ingredients: { ingredientId: string; percentage: number; processNotes?: string }[] + }[] +} + +export const formulaService = { + list(params?: { page?: number; limit?: number; search?: string }) { + const qs = new URLSearchParams() + if (params?.page) qs.set('page', String(params.page)) + if (params?.limit) qs.set('limit', String(params.limit)) + if (params?.search) qs.set('search', params.search) + const query = qs.toString() ? `?${qs.toString()}` : '' + return apiFetch>(`/api/formulas${query}`) + }, + + getById(id: string) { + return apiFetch<{ data: FormulaDetail }>(`/api/formulas/${id}`) + }, + + create(input: CreateFormulaInput) { + return apiFetch<{ data: FormulaDetail }>('/api/formulas', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) + }, + + updateMeta(id: string, data: { name?: string; description?: string }) { + return apiFetch<{ data: { id: string } }>(`/api/formulas/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + }, + + updateComposition(id: string, phases: CreateFormulaInput['phases']) { + return apiFetch<{ data: FormulaDetail }>(`/api/formulas/${id}/composition`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phases }), + }) + }, + + delete(id: string) { + return apiFetch(`/api/formulas/${id}`, { method: 'DELETE' }) + }, +} diff --git a/frontend/src/pages/ColorLabPage.tsx b/frontend/src/pages/ColorLabPage.tsx index 04e5114..bd1336c 100644 --- a/frontend/src/pages/ColorLabPage.tsx +++ b/frontend/src/pages/ColorLabPage.tsx @@ -101,7 +101,7 @@ export default function ColorLabPage() {
-

{target.label}

+

{target.label}

{delta !== null &&

ΔE = {delta.toFixed(2)}

}
@@ -112,19 +112,19 @@ export default function ColorLabPage() {
- HEX + HEX {hex} - +
-
RGB{rgb.r}, {rgb.g}, {rgb.b}
-
LabL:{currentLab.L.toFixed(1)} a:{currentLab.a.toFixed(1)} b:{currentLab.b.toFixed(1)}
+
RGB{rgb.r}, {rgb.g}, {rgb.b}
+
LabL:{currentLab.L.toFixed(1)} a:{currentLab.a.toFixed(1)} b:{currentLab.b.toFixed(1)}
{(['hex', 'rgb', 'lab', 'pantone'] as const).map(f => ( ))} @@ -145,12 +145,12 @@ export default function ColorLabPage() {
{(['lab', 'rgb'] as const).map(m => ( ))} + className="ml-1 rounded p-0.5 text-gray-300 hover:text-gray-500 dark:text-gray-400">
@@ -199,7 +199,7 @@ function SliderRow({ label, value, min, max, step, onChange, trackStyle }: { }) { return (
- {label} + {label} onChange(parseFloat(e.target.value))} className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full" diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index bed2a6a..eaf7af8 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -34,36 +34,36 @@ export default function DashboardPage() {

仪表盘

- +
{stats?.formulaCount ?? '-'}
-
配方总数
+
配方总数
- +
{stats?.ingredientCount ?? '-'}
-
成分总数
+
成分总数
- +
{stats?.projectCount ?? '-'}
-
项目数量
+
项目数量
-
+

最近更新

查看全部 diff --git a/frontend/src/pages/FormulaDetailPage.tsx b/frontend/src/pages/FormulaDetailPage.tsx index 672ec87..915c9da 100644 --- a/frontend/src/pages/FormulaDetailPage.tsx +++ b/frontend/src/pages/FormulaDetailPage.tsx @@ -56,7 +56,7 @@ export default function FormulaDetailPage() {
- {formula.description ?

{String(formula.description)}

: null} + {formula.description ?

{String(formula.description)}

: null}
v{Number(formula.currentVersion)} 更新于 {new Date(String(formula.updatedAt)).toLocaleString('zh-CN')} @@ -64,11 +64,11 @@ export default function FormulaDetailPage() {
diff --git a/frontend/src/pages/FormulaListPage.tsx b/frontend/src/pages/FormulaListPage.tsx index 6d9e65d..70c5ae4 100644 --- a/frontend/src/pages/FormulaListPage.tsx +++ b/frontend/src/pages/FormulaListPage.tsx @@ -51,30 +51,30 @@ export default function FormulaListPage() {

配方记录

- + setSearch(e.target.value)} - className="w-full rounded-lg border border-gray-300 py-2 pl-9 pr-3 text-sm focus:border-blue-500 focus:outline-none" /> + className="w-full rounded-lg border border-slate-300 py-2 pl-9 pr-3 text-sm focus:border-brand-500 focus:outline-none" />
{isLoading ? (
{Array.from({ length: 6 }).map((_, i) => ( -
-
-
-
+
+
+
+
))}
) : formulas.length === 0 ? ( -
+

暂无配方

点击"新建配方"开始创建

@@ -84,18 +84,18 @@ export default function FormulaListPage() {
{formulas.map((f) => ( -

{f.name}

+ className="group rounded-xl border border-slate-200 bg-white dark:bg-gray-700 p-5 transition-shadow hover:shadow-card-hover"> +

{f.name}

{f.description && ( -

{f.description}

+

{f.description}

)} -
+
v{f.currentVersion} {new Date(f.updatedAt).toLocaleDateString('zh-CN')} {f.project && ( - {f.project.name} + {f.project.name} )}
@@ -105,10 +105,10 @@ export default function FormulaListPage() { {pagination && pagination.totalPages > 1 && (
- {pagination.page} / {pagination.totalPages} + className="rounded-lg border px-3 py-1.5 hover:bg-slate-50 disabled:opacity-30">上一页 + {pagination.page} / {pagination.totalPages} + className="rounded-lg border px-3 py-1.5 hover:bg-slate-50 disabled:opacity-30">下一页
)} diff --git a/frontend/src/pages/IngredientsPage.tsx b/frontend/src/pages/IngredientsPage.tsx index 3ffec9e..6edfb7d 100644 --- a/frontend/src/pages/IngredientsPage.tsx +++ b/frontend/src/pages/IngredientsPage.tsx @@ -178,7 +178,7 @@ export default function IngredientsPage() { {ing.unitPrice != null ? `¥${Number(ing.unitPrice).toFixed(2)}` : '-'} - {ing.supplier ?? '-'} + {ing.supplier ?? '-'} e.stopPropagation()}>
{pagination && pagination.totalPages > 1 && ( -
+
共 {pagination.total} 条
) : ( @@ -284,7 +284,7 @@ export default function IngredientsPage() { 确认删除 - + 确定要删除成分「{deleteTarget?.chineseName}」吗?此操作不可撤销。
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 50a50e1..1c704f5 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -25,29 +25,29 @@ export default function LoginPage() { } return ( -
-
+
+
- +

配方研发平台

-

登录您的账户

+

登录您的账户

{error &&
{error}
}
setUsername(e.target.value)} - required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none" /> + required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-brand-500 focus:outline-none" /> setPassword(e.target.value)} - required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none" /> + required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-brand-500 focus:outline-none" />
-

- 还没有账户?注册 +

+ 还没有账户?注册

diff --git a/frontend/src/pages/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage.tsx index 5b7e051..1ffccd3 100644 --- a/frontend/src/pages/ProjectsPage.tsx +++ b/frontend/src/pages/ProjectsPage.tsx @@ -1,7 +1,6 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Plus, Trash2 } from 'lucide-react' - +import { Plus, Trash2, FolderKanban } from 'lucide-react' import { apiFetch } from '@/lib/api' interface Project { @@ -33,35 +32,47 @@ export default function ProjectsPage() { return (
-

项目管理

+
+

项目管理

+

管理研发项目,组织配方分类

+
-
- setName(e.target.value)} placeholder="项目名称" - className="rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /> - setDesc(e.target.value)} placeholder="描述(可选)" - className="flex-1 rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /> +
+ setName(e.target.value)} placeholder="项目名称" className="w-40" /> + setDesc(e.target.value)} placeholder="描述(可选)" className="flex-1" />
-
- {projects.map((p) => ( -
-
-

{p.name}

- + {projects.length === 0 ? ( +
+ +

暂无项目

+

在上方输入项目名称创建第一个项目

+
+ ) : ( +
+ {projects.map((p) => ( +
+
+

{p.name}

+ +
+ {p.description &&

{p.description}

} +
+ + {p._count.formulas} 个配方 + + {new Date(p.createdAt).toLocaleDateString('zh-CN')} +
- {p.description &&

{p.description}

} -
- {p._count.formulas} 个配方 - · - {new Date(p.createdAt).toLocaleDateString('zh-CN')} -
-
- ))} -
+ ))} +
+ )}
) } diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx index 4a6adc3..2e4f736 100644 --- a/frontend/src/pages/RegisterPage.tsx +++ b/frontend/src/pages/RegisterPage.tsx @@ -25,24 +25,24 @@ export default function RegisterPage() { } return ( -
-
+
+

创建账户

{error &&
{error}
}
setUsername(e.target.value)} - required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none" /> + required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-brand-500 focus:outline-none" /> setPassword(e.target.value)} - required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none" /> + required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-brand-500 focus:outline-none" /> setConfirm(e.target.value)} - required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-blue-500 focus:outline-none" /> + required className="w-full rounded-lg border px-3 py-2.5 text-sm focus:border-brand-500 focus:outline-none" />
-

- 已有账户?登录 +

+ 已有账户?登录

diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx index e6c8e03..4e5f781 100644 --- a/frontend/src/pages/SearchPage.tsx +++ b/frontend/src/pages/SearchPage.tsx @@ -27,7 +27,7 @@ export default function SearchPage() { return (

搜索结果

- {q &&

「{q}」{keywords.length > 0 && ` → AI 理解: ${keywords.join(', ')}`}

} + {q &&

「{q}」{keywords.length > 0 && ` → AI 理解: ${keywords.join(', ')}`}

} {isLoading ? (
搜索中...
@@ -42,7 +42,7 @@ export default function SearchPage() {

{f.name}

- {f.description &&

{f.description}

} + {f.description &&

{f.description}

}
{f.project && {f.project.name}}
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index b97da43..b7dc074 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -74,7 +74,7 @@ export default function SettingsPage() {

设置

-
+

外观

{([ @@ -89,11 +89,11 @@ export default function SettingsPage() {
-
+

AI 配置

-

运行模式

+

运行模式

{([ { value: true, label: 'Mock 模拟(无需 Key)' }, @@ -125,7 +125,7 @@ export default function SettingsPage() { setOpenaiBaseUrl(e.target.value)} placeholder="Base URL(可选,默认 api.openai.com)" - className="mt-1 w-full rounded-lg border py-1.5 px-3 text-xs text-gray-500 focus:border-blue-500 focus:outline-none" /> + className="mt-1 w-full rounded-lg border py-1.5 px-3 text-xs text-gray-500 dark:text-gray-400 focus:border-blue-500 focus:outline-none" />
@@ -152,7 +152,7 @@ export default function SettingsPage() { setDeepseekBaseUrl(e.target.value)} placeholder="Base URL(可选,默认 api.deepseek.com)" - className="mt-1 w-full rounded-lg border py-1.5 px-3 text-xs text-gray-500 focus:border-blue-500 focus:outline-none" /> + className="mt-1 w-full rounded-lg border py-1.5 px-3 text-xs text-gray-500 dark:text-gray-400 focus:border-blue-500 focus:outline-none" />
{testResult && ( @@ -170,9 +170,9 @@ export default function SettingsPage() {

API Key 仅存储在本地浏览器和后端内存中,不会上传到第三方

-
+

关于

-
+

配方研发智能平台 v0.1.0

AI 驱动的化妆品配方研发辅助工具

diff --git a/frontend/src/pages/VersionComparePage.tsx b/frontend/src/pages/VersionComparePage.tsx index f643bf7..fc7623a 100644 --- a/frontend/src/pages/VersionComparePage.tsx +++ b/frontend/src/pages/VersionComparePage.tsx @@ -150,7 +150,7 @@ export default function VersionComparePage() { {d.inciName} {d.chineseName} - {d.phaseName} + {d.phaseName} {d.oldPercentage != null ? `${d.oldPercentage.toFixed(2)}%` : '-'} {d.newPercentage != null ? `${d.newPercentage.toFixed(2)}%` : '-'} 0.01 ? 'text-green-600' : d.change < -0.01 ? 'text-red-600' : 'text-gray-400'}`}> diff --git a/frontend/src/pages/VersionHistoryPage.tsx b/frontend/src/pages/VersionHistoryPage.tsx index 68ed250..e2d67cd 100644 --- a/frontend/src/pages/VersionHistoryPage.tsx +++ b/frontend/src/pages/VersionHistoryPage.tsx @@ -56,7 +56,7 @@ export default function VersionHistoryPage() { {versions.map((v, i) => (
-
+
- {v.description &&

{v.description}

} + {v.description &&

{v.description}

} @@ -75,10 +75,10 @@ export default function VersionHistoryPage() {
{v.phases.map(p => (
-

{p.name}

+

{p.name}

{p.ingredients.map((ing, j) => ( -
+
{ing.ingredient.inciName} {ing.ingredient.chineseName} {Number(ing.percentage).toFixed(2)}%
diff --git a/frontend/src/shared/components/Alert.tsx b/frontend/src/shared/components/Alert.tsx new file mode 100644 index 0000000..92740bb --- /dev/null +++ b/frontend/src/shared/components/Alert.tsx @@ -0,0 +1,39 @@ +import clsx from 'clsx' +import { AlertCircle, CheckCircle, Info, XCircle } from 'lucide-react' +import type { ReactNode } from 'react' + +type AlertVariant = 'error' | 'success' | 'info' | 'warning' + +interface AlertProps { + variant?: AlertVariant + title?: string + children: ReactNode + className?: string + onClose?: () => void +} + +const config: Record = { + error: { icon: XCircle, style: 'border-red-200 bg-red-50 text-red-800' }, + success: { icon: CheckCircle, style: 'border-green-200 bg-green-50 text-green-800' }, + warning: { icon: AlertCircle, style: 'border-yellow-200 bg-yellow-50 text-yellow-800' }, + info: { icon: Info, style: 'border-blue-200 bg-blue-50 text-blue-800' }, +} + +export function Alert({ variant = 'info', title, children, className, onClose }: AlertProps) { + const { icon: Icon, style } = config[variant] + + return ( +
+ +
+ {title &&

{title}

} +
{children}
+
+ {onClose && ( + + )} +
+ ) +} diff --git a/frontend/src/shared/components/Skeleton.tsx b/frontend/src/shared/components/Skeleton.tsx new file mode 100644 index 0000000..361e402 --- /dev/null +++ b/frontend/src/shared/components/Skeleton.tsx @@ -0,0 +1,53 @@ +import clsx from 'clsx' + +interface SkeletonProps { + className?: string + lines?: number + rounded?: boolean +} + +export function Skeleton({ className, lines = 1, rounded }: SkeletonProps) { + if (lines > 1) { + return ( +
+ {Array.from({ length: lines }).map((_, i) => ( +
+ ))} +
+ ) + } + + return ( +
+ ) +} + +export function PageSkeleton() { + return ( +
+ +
+ + + +
+
+ +
+
+ ) +} diff --git a/frontend/src/shared/components/Toast.tsx b/frontend/src/shared/components/Toast.tsx new file mode 100644 index 0000000..f15d62e --- /dev/null +++ b/frontend/src/shared/components/Toast.tsx @@ -0,0 +1,87 @@ +import { useState, useCallback, createContext, useContext, type ReactNode } from 'react' +import { X, CheckCircle, AlertCircle, Info, Loader2 } from 'lucide-react' +import clsx from 'clsx' + +type ToastType = 'success' | 'error' | 'info' | 'loading' + +interface ToastItem { + id: number + type: ToastType + message: string + duration?: number +} + +interface ToastContextValue { + toast: (message: string, type?: ToastType, duration?: number) => void +} + +const ToastContext = createContext(null) + +export function useToast() { + const ctx = useContext(ToastContext) + if (!ctx) throw new Error('useToast must be used within ToastProvider') + return ctx +} + +const icons: Record = { + success: CheckCircle, + error: AlertCircle, + info: Info, + loading: Loader2, +} + +const colors: Record = { + success: 'border-green-200 bg-green-50 text-green-800', + error: 'border-red-200 bg-red-50 text-red-800', + info: 'border-blue-200 bg-blue-50 text-blue-800', + loading: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-700 text-gray-700', +} + +let nextId = 0 + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + + const add = useCallback((message: string, type: ToastType = 'info', duration = 4000) => { + const id = nextId++ + setToasts(prev => [...prev, { id, type, message, duration }]) + if (duration > 0 && type !== 'loading') { + setTimeout(() => remove(id), duration) + } + }, []) + + const remove = useCallback((id: number) => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, []) + + const toast = useCallback((message: string, type?: ToastType, duration?: number) => { + add(message, type, duration) + }, [add]) + + return ( + + {children} +
+ {toasts.map(t => { + const Icon = icons[t.type] + return ( +
+ + {t.message} + +
+ ) + })} +
+
+ ) +} diff --git a/frontend/src/shared/services/api.ts b/frontend/src/shared/services/api.ts new file mode 100644 index 0000000..2d44eea --- /dev/null +++ b/frontend/src/shared/services/api.ts @@ -0,0 +1,52 @@ +export class ApiError extends Error { + status: number + code?: string + + constructor(status: number, message: string, code?: string) { + super(message) + this.name = 'ApiError' + this.status = status + this.code = code + } +} + +function getAuthHeaders(): Record { + try { + const raw = localStorage.getItem('auth-storage') + if (!raw) return {} + const parsed = JSON.parse(raw) as { state?: { token?: string } } + const token = parsed?.state?.token + return token ? { Authorization: `Bearer ${token}` } : {} + } catch { + return {} + } +} + +export async function apiFetch(url: string, options?: RequestInit): Promise { + const headers = new Headers(options?.headers) + const authHeaders = getAuthHeaders() + for (const [key, value] of Object.entries(authHeaders)) { + headers.set(key, value) + } + + const res = await fetch(url, { ...options, headers }) + if (res.status === 204) return undefined as T + + const text = await res.text() + if (!text) { + if (!res.ok) throw new ApiError(res.status, `请求失败 (${res.status})`) + return undefined as T + } + + try { + const data = JSON.parse(text) as T & { error?: string; code?: string } + if (!res.ok) { + throw new ApiError(res.status, data.error ?? `请求失败 (${res.status})`, data.code) + } + return data + } catch (e) { + if (e instanceof ApiError) throw e + if (!res.ok) throw new ApiError(res.status, `请求失败 (${res.status})`) + throw new ApiError(res.status, '响应格式错误') + } +} diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh new file mode 100755 index 0000000..326f50b --- /dev/null +++ b/scripts/backup-db.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -euo pipefail + +BACKUP_DIR="${BACKUP_DIR:-/var/backups/colorfull}" +RETENTION_DAYS="${RETENTION_DAYS:-7}" +DB_CONTAINER="${DB_CONTAINER:-colorfull-db}" +DB_NAME="${DB_NAME:-colorfull}" +DB_USER="${DB_USER:-colorfull}" + +mkdir -p "$BACKUP_DIR" + +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="$BACKUP_DIR/colorfull_${TIMESTAMP}.sql.gz" + +echo "[$(date)] Starting backup to $BACKUP_FILE" + +docker exec "$DB_CONTAINER" pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "$BACKUP_FILE" + +echo "[$(date)] Backup complete: $(ls -lh "$BACKUP_FILE" | awk '{print $5}')" + +find "$BACKUP_DIR" -name "colorfull_*.sql.gz" -mtime +"$RETENTION_DAYS" -delete + +echo "[$(date)] Cleaned up backups older than $RETENTION_DAYS days" +echo "[$(date)] Current backups: $(ls "$BACKUP_DIR" | wc -l) files"