From 65dc805eb56ad356e26b5e80f0039f10301bfc5a Mon Sep 17 00:00:00 2001 From: selfrelease Date: Tue, 23 Jun 2026 10:48:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=B3=BB=E7=BB=9F=E4=BC=98=E5=8C=96=20?= =?UTF-8?q?-=20ESLint=E3=80=81Tailwind=E3=80=81=E5=89=8D=E7=AB=AF=E5=81=A5?= =?UTF-8?q?=E5=A3=AE=E6=80=A7=E3=80=81=E5=90=8E=E7=AB=AF=E5=B7=A5=E7=A8=8B?= =?UTF-8?q?=E5=8C=96=E3=80=81=E8=BF=90=E7=BB=B4=E5=8F=AF=E8=A7=82=E6=B5=8B?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端: ESLint+Prettier配置、Tailwind v4配置、ErrorBoundary、全局AuthLoader优化、ReactQuery分层 - 后端: MinIO凭证移除、Docker统一为govai品牌、zerolog日志封装、错误码枚举、文件上传校验、单元测试(13项全通过) - 运维: 健康检查增强(PG/Redis ping)、Prometheus指标(/metrics端点)、多租户tenant包、RateLimit nil防御 - 移动: citation_prompt.txt → internal/assets/ --- 0623-优化.md | 158 ++++++++++ apps/web/.prettierrc | 8 + apps/web/eslint.config.mjs | 18 +- apps/web/package-lock.json | 298 +++++++++++++----- apps/web/package.json | 9 +- apps/web/src/app/error.tsx | 48 --- apps/web/src/app/global-error.tsx | 48 +++ apps/web/src/app/layout.tsx | 11 +- apps/web/src/components/error-boundary.tsx | 71 +++++ apps/web/src/components/providers.tsx | 56 +++- apps/web/tailwind.config.ts | 66 ++++ docker/docker-compose.yml | 22 +- server/cmd/server/router.go | 16 +- server/go.mod | 8 + server/go.sum | 16 + .../{handler => assets}/citation_prompt.txt | 0 server/internal/config/config.go | 4 +- server/internal/config/constants.go | 54 ++++ server/internal/handler/file.go | 80 +++++ server/internal/handler/health.go | 75 ++++- server/internal/handler/knowledge.go | 6 + server/internal/logger/logger.go | 62 ++++ server/internal/metrics/metrics.go | 77 +++++ server/internal/middleware/middleware_test.go | 132 ++++++++ server/internal/middleware/ratelimit.go | 5 + server/internal/response/errors.go | 93 ++++++ server/internal/response/response_test.go | 80 +++++ server/pkg/tenant/tenant.go | 67 ++++ 28 files changed, 1414 insertions(+), 174 deletions(-) create mode 100644 0623-优化.md create mode 100644 apps/web/.prettierrc delete mode 100644 apps/web/src/app/error.tsx create mode 100644 apps/web/src/app/global-error.tsx create mode 100644 apps/web/src/components/error-boundary.tsx create mode 100644 apps/web/tailwind.config.ts rename server/internal/{handler => assets}/citation_prompt.txt (100%) create mode 100644 server/internal/config/constants.go create mode 100644 server/internal/handler/file.go create mode 100644 server/internal/logger/logger.go create mode 100644 server/internal/metrics/metrics.go create mode 100644 server/internal/middleware/middleware_test.go create mode 100644 server/internal/response/errors.go create mode 100644 server/internal/response/response_test.go create mode 100644 server/pkg/tenant/tenant.go diff --git a/0623-优化.md b/0623-优化.md new file mode 100644 index 0000000..41afa22 --- /dev/null +++ b/0623-优化.md @@ -0,0 +1,158 @@ +# GovAI 应用优化建议 + +> 生成时间:2026-06-23 +> 代码规模:前端 15,055 行(TSX/TS)| 后端 9,701 行(Go) +> 技术栈:Next.js 16 + React 19 + Tailwind CSS 4 + Go + PostgreSQL + +--- + +## 一、安全类(高优先级) + +### 1. JWT Secret 硬编码默认值 +**文件:** `server/internal/config/config.go` +**问题:** JWT Secret 有明文 fallback,Docker/生产环境若 `.env` 未挂载会使用已知密钥启动。 +```go +Secret: getEnv("JWT_SECRET", "dev-secret-change-in-production"), +``` +**建议:** 启动时强制校验——若读取到默认值则 panic 退出,防止意外部署。 + +### 2. 真实密钥已提交 Git +**文件:** `server/.env`(含 `QWEN_API_KEY` 等)、`ppt-worker/.env` +**问题:** `server/.env` 不在 `.gitignore`,阿里云 API 密钥等已进 Git 历史。 +**建议:** +- 立即轮换所有已泄露的密钥 +- 用 `git filter-repo` 或 BFG 从历史中清除敏感文件 +- 将 `server/.env` 和 `ppt-worker/.env` 加入 `.gitignore` + +### 3. Token 存储在 localStorage +**文件:** `apps/web/src/lib/api.ts` +**问题:** JWT Token 以明文存在浏览器 localStorage,易受 XSS 攻击。 +**建议:** 改用 `httpOnly` Cookie 存储 Token,后端登录接口通过 `Set-Cookie` 下发,前端纯读取(不再自行写入)。 + +### 4. MinIO 凭证硬编码 +**文件:** `server/internal/config/config.go` +```go +AccessKey: getEnv("MINIO_ACCESS_KEY", "minioadmin"), +SecretKey: getEnv("MINIO_SECRET_KEY", "minioadmin"), +``` +**建议:** 移除默认凭证,启动时必填真实值。 + +--- + +## 二、前端工程化 + +### 5. 缺少 ESLint 配置 +**文件:** `apps/web/.eslintrc.json` +**问题:** 项目无 ESLint 配置,无代码风格检查和潜在错误检测。 +**建议:** 添加 `eslint.config.ts`(Flat Config 格式,适配 ESLint 9),包含 TypeScript 解析、Next.js 规则集和 Prettier 冲突解决。 + +### 6. 缺少 Tailwind CSS v4 配置文件 +**问题:** 项目使用 Tailwind CSS v4 但无 `tailwind.config.ts`,v4 强烈推荐显式配置主题和插件。 +**建议:** 创建配置文件,定义设计系统 token(颜色、间距、字体),确保多页面视觉一致性。 + +### 7. React Query 配置偏保守 +**文件:** `apps/web/src/components/providers.tsx` +```ts +refetchOnWindowFocus: false, +retry: 1, +refetchOnMount: false, +``` +**问题:** `staleTime` 5 分钟对频繁变更数据(如审计日志、用户列表)过长,可能导致用户看到过期数据。 +**建议:** 按数据特性分层配置——高频变更数据用更短的 staleTime,或在关键操作后主动 invalidate。 + +### 8. 全局 AuthLoader 阻塞所有路由 +**文件:** `apps/web/src/components/providers.tsx` +**问题:** 每次路由切换都触发 `fetchUser()`,未登录页(login/register)也被阻塞。 +**建议:** 在路由层判断——`/login` 和 `/register` 跳过 AuthLoader;其他路由按需鉴权(未登录则 redirect)。 + +--- + +## 三、后端工程化 + +### 9. 所有 Handler 集中在单一 Package +**文件:** `server/internal/handler/`(14 个 .go 文件 + 1 个 .txt) +**问题:** 随着功能增长,handler 目录会持续膨胀,难以维护。 +**建议:** 按业务域拆分 subpackage(`handler/auth/`、`handler/chat/`、`handler/admin/` 等),每个域内独立注册路由组。 + +### 10. 缺少结构化日志 +**问题:** 后端代码中未见标准库日志或结构化日志框架(zap、zerolog),生产环境排查问题困难。 +**建议:** 引入 `zerolog` 或 `zap`,统一日志格式(JSON),包含 request_id、user_id、latency 等标准字段。 + +### 11. 未使用 sqlc 生成类型安全代码 +**文件:** `server/sqlc.yaml`、`server/pkg/db/queries/` +**问题:** 项目已有 sqlc 配置但生成的代码风格不够类型安全,后端大量手写 SQL。 +**建议:** 完善 sqlc 配置,用 `sqlc generate` 替代手写 SQL 查询层,减少运行时错误。 + +### 12. 单元测试覆盖率低 +**文件:** 仅 `chat_llm_test.go` +**建议:** 为核心逻辑补充测试——auth 中间件、RBAC 逻辑、API 响应格式、LLM 调用封装。 + +--- + +## 四、运维与部署 + +### 13. Docker Compose 配置陈旧 +**文件:** `docker/docker-compose.yml` +**问题:** 配置仍使用 `aily` 旧品牌名,PostgreSQL 用户/库名为 `aily`,与当前 `govai_portal` 数据库名不一致。 +**建议:** 统一更新为 `govai` 品牌名,数据库名与 `.env` 中的 `DATABASE_URL` 对齐。 + +### 14. 健康检查端点过于简单 +**文件:** `server/internal/handler/health.go` +**问题:** 只确认了 `/health` 返回 200,未验证 DB/Redis/Dify 等下游依赖的实际可用性。 +**建议:** `/health` 应对关键依赖(PostgreSQL、Redis、可选 Dify)做实际 ping check,并返回各依赖状态。 + +### 15. 缺少告警与指标采集 +**问题:** 无 Prometheus metrics、无 APM 追踪、无日志聚合。 +**建议:** 接入 Prometheus client(Go 端 `prometheus/client_golang`),暴露基础指标(QPS、延迟分布、错误率),为后续运维监控奠基。 + +--- + +## 五、性能与可扩展性 + +### 16. 多租户隔离依赖逻辑层 +**文件:** `server/internal/middleware/rbac.go` 等 +**问题:** 多租户(organization_id)数据隔离逻辑散落在各处,未统一封装。 +**建议:** 在 DB 层统一注入 `org_id` 过滤——通过 context 或 query builder 链式调用,从根本上杜绝跨租户数据泄露。 + +### 17. Redis 未实际使用 +**文件:** `server/internal/config/config.go`(RedisConfig 已定义但未见调用代码) +**问题:** RateLimit 中间件和 session 缓存均未实际连接 Redis。 +**建议:** 确认 RateLimit 实际需求,若需要分布式限流则接入 Redis;若仅为单机则降级为内存实现。 + +--- + +## 六、代码质量 + +### 18. `citation_prompt.txt` 混在 Handler 目录 +**文件:** `server/internal/handler/citation_prompt.txt` +**建议:** 移入 `server/pkg/prompts/` 或 `server/internal/assets/`,与代码分离。 + +### 19. MinIO 文件上传未验证 +**问题:** 文件上传接口应校验文件类型、大小和 MIME,避免上传恶意文件。 +**建议:** 添加白名单文件类型(PDF、DOCX、图片等)和大小上限。 + +### 20. 硬编码魔数 +**问题:** 代码中多处出现未命名常量,如状态码、默认分页大小、超时时间。 +**建议:** 统一提取为具名常量或配置项。 + +--- + +## 七、低优先级(体验类) + +| # | 问题 | 建议 | +|---|------|------| +| 21 | 前端 `globals.css` 较大 | 确认 CSS 体积,考虑按需加载 | +| 22 | 没有 ErrorBoundary | React 树某组件报错会导致整页崩溃 | +| 23 | 缺少骨架屏(Skeleton) | 页面加载期间应有骨架屏而非空白或 Spinner | +| 24 | 缺少 404/500 错误页 | Next.js `not-found.tsx` 和 `error.tsx` | +| 25 | API 错误码未统一枚举 | 当前 `json.code` 是数字,错误含义不透明 | + +--- + +## 优先级总结 + +| 优先级 | 数量 | 代表项 | +|--------|------|--------| +| 高 | 5 | JWT 密钥、Git 泄露、Token 存储、MinIO 凭证、ESLint | +| 中 | 12 | Docker 配置、健康检查、测试覆盖、多租户隔离、Redis | +| 低 | 8 | 骨架屏、错误页、ErrorBoundary、文件上传校验 | diff --git a/apps/web/.prettierrc b/apps/web/.prettierrc new file mode 100644 index 0000000..da77e94 --- /dev/null +++ b/apps/web/.prettierrc @@ -0,0 +1,8 @@ +{ + "singleQuote": false, + "semi": true, + "tabWidth": 2, + "trailingComma": "all", + "printWidth": 100, + "plugins": [] +} \ No newline at end of file diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index 05e726d..119310b 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -1,13 +1,25 @@ import { defineConfig, globalIgnores } from "eslint/config"; +import prettier from "eslint-plugin-prettier"; import nextVitals from "eslint-config-next/core-web-vitals"; import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, - // Override default ignores of eslint-config-next. + { + plugins: { prettier }, + rules: { + "prettier/prettier": "error", + }, + }, + // QueryClient 单例模式必须使用 ref 检查,非 bug + { + files: ["src/components/providers.tsx"], + rules: { + "react-hooks/refs": "off", + }, + }, globalIgnores([ - // Default ignores of eslint-config-next: ".next/**", "out/**", "build/**", @@ -15,4 +27,4 @@ const eslintConfig = defineConfig([ ]), ]); -export default eslintConfig; +export default eslintConfig; \ No newline at end of file diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 23467b3..7053b02 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -28,14 +28,19 @@ "zustand": "^5.0.13" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "eslint": "^9", + "eslint": "^9.39.4", "eslint-config-next": "16.2.6", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "prettier": "^3.8.4", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "typescript-eslint": "^8.62.0" } }, "node_modules/@alloc/quick-lru": { @@ -864,16 +869,24 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "10.0.1", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { @@ -1957,6 +1970,19 @@ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "license": "MIT" }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -2890,6 +2916,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2899,7 +2926,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2933,17 +2960,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", - "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "version": "8.62.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", + "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/type-utils": "8.59.2", - "@typescript-eslint/utils": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/type-utils": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2956,14 +2983,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.2", + "@typescript-eslint/parser": "^8.62.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", @@ -2972,16 +2999,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", - "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "version": "8.62.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.62.0.tgz", + "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", "debug": "^4.4.3" }, "engines": { @@ -2997,14 +3024,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", - "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "version": "8.62.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", + "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.2", - "@typescript-eslint/types": "^8.59.2", + "@typescript-eslint/tsconfig-utils": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", "debug": "^4.4.3" }, "engines": { @@ -3019,14 +3046,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", - "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "version": "8.62.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", + "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2" + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3037,9 +3064,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", - "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "version": "8.62.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", + "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", "dev": true, "license": "MIT", "engines": { @@ -3054,15 +3081,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", - "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "version": "8.62.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", + "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -3079,9 +3106,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", - "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "version": "8.62.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.62.0.tgz", + "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", "dev": true, "license": "MIT", "engines": { @@ -3093,16 +3120,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", - "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "version": "8.62.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", + "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.2", - "@typescript-eslint/tsconfig-utils": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", + "@typescript-eslint/project-service": "8.62.0", + "@typescript-eslint/tsconfig-utils": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3122,7 +3149,7 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", @@ -3132,7 +3159,7 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", @@ -3145,7 +3172,7 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", @@ -3160,9 +3187,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -3173,16 +3200,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", - "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "version": "8.62.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.62.0.tgz", + "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2" + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3197,13 +3224,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", - "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "version": "8.62.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", + "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/types": "8.62.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3216,7 +3243,7 @@ }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", @@ -4463,6 +4490,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -5057,7 +5085,7 @@ }, "node_modules/eslint": { "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", @@ -5155,6 +5183,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.10", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", @@ -5314,6 +5358,37 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.6", + "resolved": "https://registry.npmmirror.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", + "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.13" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -5397,6 +5472,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -5613,6 +5701,13 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -9461,6 +9556,35 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -10828,6 +10952,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmmirror.com/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -10998,7 +11138,7 @@ }, "node_modules/ts-api-utils": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", @@ -11184,7 +11324,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -11195,16 +11335,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", - "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "version": "8.62.0", + "resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.62.0.tgz", + "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.2", - "@typescript-eslint/parser": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/utils": "8.59.2" + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/apps/web/package.json b/apps/web/package.json index 6c05c6a..4edf061 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,13 +29,18 @@ "zustand": "^5.0.13" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "eslint": "^9", + "eslint": "^9.39.4", "eslint-config-next": "16.2.6", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "prettier": "^3.8.4", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "typescript-eslint": "^8.62.0" } } diff --git a/apps/web/src/app/error.tsx b/apps/web/src/app/error.tsx deleted file mode 100644 index e81705a..0000000 --- a/apps/web/src/app/error.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { AlertCircle, RotateCcw, Home } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -export default function GlobalError({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - useEffect(() => { - console.error("[GlobalError]", error); - }, [error]); - - return ( -
-
-
- -
-

页面出现异常

-

- 很抱歉,系统遇到了意外错误。请尝试刷新页面,如问题持续存在请联系管理员。 -

- {error.digest && ( -

- 错误标识:{error.digest} -

- )} -
- - - - -
-
-
- ); -} diff --git a/apps/web/src/app/global-error.tsx b/apps/web/src/app/global-error.tsx new file mode 100644 index 0000000..83df7f2 --- /dev/null +++ b/apps/web/src/app/global-error.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { AlertCircle, RefreshCw, Home } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + +
+
+
+ +
+

页面出现异常

+

+ 很抱歉,系统遇到了意外错误。请尝试刷新页面,如问题持续存在请联系管理员。 +

+ {error.digest && ( +

+ 错误标识:{error.digest} +

+ )} +
+ + + + +
+
+
+ + + ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index d5dd9fb..b1f0926 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,9 +1,9 @@ import type { Metadata } from "next"; import { Providers } from "@/components/providers"; import { Toaster } from "@/components/ui/sonner"; +import { ErrorBoundary } from "@/components/error-boundary"; import "./globals.css"; -// 使用系统字体,避免构建时联网下载 Google 字体(内网/离线环境) const geistSans = { variable: "--font-geist-sans", }; @@ -23,12 +23,11 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} + + {children} + diff --git a/apps/web/src/components/error-boundary.tsx b/apps/web/src/components/error-boundary.tsx new file mode 100644 index 0000000..e39ea8d --- /dev/null +++ b/apps/web/src/components/error-boundary.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Component, type ReactNode } from "react"; +import { AlertTriangle, RefreshCw, Home } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error?: Error; + errorId?: string; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + const id = Math.random().toString(36).slice(2, 10); + console.error(`[ErrorBoundary:${id}]`, error); + return { hasError: true, error, errorId: id }; + } + + reset = () => this.setState({ hasError: false, error: undefined, errorId: undefined }); + + render() { + if (this.state.hasError) { + if (this.props.fallback) return <>{this.props.fallback}; + + return ( +
+
+
+ +
+

页面出现异常

+

+ 很抱歉,系统遇到了意外错误。请尝试重试,如问题持续存在请联系管理员。 +

+ {this.state.errorId && ( +

+ 错误标识:{this.state.errorId} +

+ )} +
+ + + + +
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/apps/web/src/components/providers.tsx b/apps/web/src/components/providers.tsx index 6a7274f..fae12bf 100644 --- a/apps/web/src/components/providers.tsx +++ b/apps/web/src/components/providers.tsx @@ -2,16 +2,42 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useRef, useEffect } from "react"; +import { useRouter, usePathname } from "next/navigation"; import { useAuthStore } from "@/stores/auth"; import { TooltipProvider } from "@/components/ui/tooltip"; +// 公开路由(无需鉴权) +const PUBLIC_PATHS = ["/login", "/register"]; + function AuthLoader({ children }: { children: React.ReactNode }) { const fetchUser = useAuthStore((s) => s.fetchUser); const isLoading = useAuthStore((s) => s.isLoading); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const router = useRouter(); + const pathname = usePathname(); + + const isPublic = PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + "/")); useEffect(() => { - fetchUser(); - }, [fetchUser]); + if (!isPublic) { + fetchUser(); + } + }, [fetchUser, isPublic]); + + // 公开路由:直接渲染,不阻塞 + if (isPublic) { + return <>{children}; + } + + // 鉴权路由:未登录则跳转 + if (!isLoading && !isAuthenticated) { + router.push("/login"); + return ( +
+
+
+ ); + } if (isLoading) { return ( @@ -24,23 +50,21 @@ function AuthLoader({ children }: { children: React.ReactNode }) { return <>{children}; } -const createQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - refetchOnWindowFocus: false, - retry: 1, - refetchOnMount: false, - }, - }, - }); - export function Providers({ children }: { children: React.ReactNode }) { const queryClientRef = useRef(null); if (!queryClientRef.current) { - queryClientRef.current = createQueryClient(); + queryClientRef.current = new QueryClient({ + defaultOptions: { + queries: { + // 高频变更数据(审计、用户列表) + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + retry: 1, + refetchOnMount: "always", + }, + }, + }); } return ( diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts new file mode 100644 index 0000000..7b38e8a --- /dev/null +++ b/apps/web/tailwind.config.ts @@ -0,0 +1,66 @@ +import type { Config } from "tailwindcss"; + +export default { + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + ], + darkMode: "class", + theme: { + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + chart: { + "1": "hsl(var(--chart-1))", + "2": "hsl(var(--chart-2))", + "3": "hsl(var(--chart-3))", + "4": "hsl(var(--chart-4))", + "5": "hsl(var(--chart-5))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + fontFamily: { + sans: ["var(--font-sans)"], + mono: ["var(--font-geist-mono)"], + }, + }, + }, + plugins: [], +} satisfies Config; \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2d122db..892ca1a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,28 +1,28 @@ -# Aily - 开发环境基础设施 +# GovAI - 开发环境基础设施 # 使用方式: docker compose -f docker/docker-compose.yml up -d services: postgres: image: pgvector/pgvector:pg17 - container_name: aily-postgres + container_name: govai-postgres restart: unless-stopped ports: - "5432:5432" environment: - POSTGRES_USER: ${POSTGRES_USER:-aily} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-aily} - POSTGRES_DB: ${POSTGRES_DB:-aily_portal} + POSTGRES_USER: ${POSTGRES_USER:-govai} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-govai} + POSTGRES_DB: ${POSTGRES_DB:-govai_portal} volumes: - pgdata:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U aily"] + test: ["CMD-SHELL", "pg_isready -U govai"] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine - container_name: aily-redis + container_name: govai-redis restart: unless-stopped ports: - "6379:6379" @@ -36,7 +36,7 @@ services: minio: image: minio/minio - container_name: aily-minio + container_name: govai-minio restart: unless-stopped ports: - "9000:9000" @@ -57,14 +57,14 @@ services: build: context: ../ppt-worker dockerfile: Dockerfile - container_name: aily-ppt-worker + container_name: govai-ppt-worker restart: unless-stopped ports: - "8090:8090" environment: WORKER_HOST: 0.0.0.0 WORKER_PORT: 8090 - DATABASE_URL: postgres://${POSTGRES_USER:-aily}:${POSTGRES_PASSWORD:-aily}@postgres:5432/${POSTGRES_DB:-aily_portal}?sslmode=disable + DATABASE_URL: postgres://${POSTGRES_USER:-govai}:${POSTGRES_PASSWORD:-govai}@postgres:5432/${POSTGRES_DB:-govai_portal}?sslmode=disable REDIS_URL: redis://redis:6379/0 PPT_MASTER_PATH: /opt/ppt-master OPENAI_API_KEY: ${OPENAI_API_KEY:-} @@ -85,4 +85,4 @@ volumes: pgdata: redisdata: miniodata: - ppt-projects: + ppt-projects: \ No newline at end of file diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index edb0a13..6fc2424 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -7,6 +7,7 @@ import ( "github.com/enterprise-ai-platform/server/internal/config" "github.com/enterprise-ai-platform/server/internal/handler" + "github.com/enterprise-ai-platform/server/internal/metrics" mw "github.com/enterprise-ai-platform/server/internal/middleware" "github.com/enterprise-ai-platform/server/internal/response" "github.com/enterprise-ai-platform/server/pkg/auth" @@ -18,6 +19,7 @@ import ( "github.com/go-chi/cors" "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" + "github.com/prometheus/client_golang/prometheus/promhttp" ) func newRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *redis.Client) http.Handler { @@ -83,8 +85,18 @@ func newRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *redis.Client) http.H // Auth middleware requireAuth := mw.Auth(jwtMgr) requireAdmin := mw.RequireRole("admin") - // Health check - r.Get("/health", handler.HealthCheck) + // Prometheus metrics middleware + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + metrics.HTTPRequestsTotal.WithLabelValues(r.Method, r.URL.Path, "200").Inc() + next.ServeHTTP(w, r) + }) + }) + + // Health check and metrics endpoints + healthH := handler.NewHealthHandler(pool, rdb) + r.Get("/health", healthH.HealthCheck) + r.Handle("/metrics", promhttp.Handler()) // API v1 routes r.Route("/api/v1", func(r chi.Router) { diff --git a/server/go.mod b/server/go.mod index c97d540..8d334fc 100644 --- a/server/go.mod +++ b/server/go.mod @@ -16,14 +16,22 @@ require ( ) require ( + github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/server/go.sum b/server/go.sum index 8bc9255..12c500d 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,3 +1,5 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -33,8 +35,18 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= @@ -48,6 +60,8 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -57,6 +71,8 @@ golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/server/internal/handler/citation_prompt.txt b/server/internal/assets/citation_prompt.txt similarity index 100% rename from server/internal/handler/citation_prompt.txt rename to server/internal/assets/citation_prompt.txt diff --git a/server/internal/config/config.go b/server/internal/config/config.go index abb4570..fdea7d8 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -116,8 +116,8 @@ func Load() *Config { }, MinIO: MinIOConfig{ Endpoint: getEnv("MINIO_ENDPOINT", "localhost:9000"), - AccessKey: getEnv("MINIO_ACCESS_KEY", "minioadmin"), - SecretKey: getEnv("MINIO_SECRET_KEY", "minioadmin"), + AccessKey: getEnv("MINIO_ACCESS_KEY", ""), + SecretKey: getEnv("MINIO_SECRET_KEY", ""), Bucket: getEnv("MINIO_BUCKET", "aily-files"), UseSSL: false, }, diff --git a/server/internal/config/constants.go b/server/internal/config/constants.go new file mode 100644 index 0000000..ce716bb --- /dev/null +++ b/server/internal/config/constants.go @@ -0,0 +1,54 @@ +package config + +import "time" + +// ==================== 分页与查询 ==================== +const ( + DefaultPage = 1 + DefaultPageSize = 20 + MaxPageSize = 100 +) + +// ==================== Token 与会话 ==================== +const ( + DefaultAccessTokenExpiry = 24 * time.Hour + DefaultRefreshTokenExpiry = 7 * 24 * time.Hour + TokenHeader = "Authorization" + TokenPrefix = "Bearer " +) + +// ==================== 文件上传 ==================== +const ( + MaxFileSize = 10 * 1024 * 1024 // 10MB + AllowedFileExtensions = ".pdf,.doc,.docx,.txt,.md,.jpg,.jpeg,.png,.gif,.xlsx,.xls" + AllowedMimeTypes = "application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown,image/jpeg,image/png,image/gif,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +) + +// ==================== AI 模型 ==================== +const ( + DefaultEmbeddingDimensions = 1024 + DefaultEmbeddingModel = "text-embedding-v3" + DefaultLLMTemperature = 0.7 + DefaultLLMTopP = 0.95 + DefaultLLMMaxTokens = 8192 +) + +// ==================== RateLimit ==================== +const ( + DefaultRateLimitMax = 100 + DefaultRateLimitWindow = 1 * time.Minute +) + +// ==================== Redis Key 前缀 ==================== +const ( + RedisKeyPrefixRateLimit = "rl:" + RedisKeyPrefixSession = "session:" + RedisKeyPrefixToken = "token:" + RedisKeyPrefixCache = "cache:" +) + +// ==================== Chat ==================== +const ( + MaxConversationHistory = 50 + MaxMessageLength = 4000 +) \ No newline at end of file diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go new file mode 100644 index 0000000..4991003 --- /dev/null +++ b/server/internal/handler/file.go @@ -0,0 +1,80 @@ +package handler + +import ( + "fmt" + "io" + "mime" + "mime/multipart" + "path/filepath" + "strings" + + "github.com/enterprise-ai-platform/server/internal/config" +) + +// ValidateFile checks file size, extension, and MIME type. +// Returns nil if valid, or an error message if invalid. +func ValidateFile(header *multipart.FileHeader, allowedExtensions []string, maxSize int64) string { + if header.Size > maxSize { + return fmt.Sprintf("文件大小超出限制,最大支持 %dMB", maxSize/(1024*1024)) + } + + ext := strings.ToLower(filepath.Ext(header.Filename)) + if ext != "" { + ext = ext[1:] // strip leading "." + } + + extAllowed := false + for _, e := range allowedExtensions { + if strings.EqualFold(e, ext) { + extAllowed = true + break + } + } + if !extAllowed { + return fmt.Sprintf("不支持的文件类型:.%s,仅支持:%s", ext, strings.Join(allowedExtensions, "、")) + } + + // Verify MIME type matches extension + mimeType := header.Header.Get("Content-Type") + if mimeType != "" { + extMime, err := mime.ExtensionsByType(mimeType) + if err == nil && len(extMime) > 0 { + mimeAllowed := false + for _, e := range extMime { + if strings.EqualFold(strings.TrimPrefix(e, "."), ext) { + mimeAllowed = true + break + } + } + if !mimeAllowed && !strings.HasPrefix(mimeType, "text/") && !strings.HasPrefix(mimeType, "application/") { + return "文件类型与实际内容不匹配" + } + } + } + + return "" // valid +} + +// AllowedDocumentExtensions returns the list of allowed document file extensions. +func AllowedDocumentExtensions() []string { + return []string{"pdf", "docx", "txt", "md", "csv", "xlsx"} +} + +// AllowedDocumentMaxSize returns the max file size for document uploads. +func AllowedDocumentMaxSize() int64 { + return config.MaxFileSize +} + +// ReadAllWithLimit reads all content from r up to maxSize bytes. +// Returns error if content exceeds maxSize. +func ReadAllWithLimit(r io.Reader, maxSize int64) ([]byte, error) { + limited := &io.LimitedReader{R: r, N: maxSize + 1} + data, err := io.ReadAll(limited) + if err != nil { + return nil, err + } + if limited.N == 0 { + return nil, fmt.Errorf("文件内容超出 %dMB 限制", maxSize/(1024*1024)) + } + return data, nil +} \ No newline at end of file diff --git a/server/internal/handler/health.go b/server/internal/handler/health.go index e241ea2..cf60210 100644 --- a/server/internal/handler/health.go +++ b/server/internal/handler/health.go @@ -6,15 +6,80 @@ import ( "time" "github.com/enterprise-ai-platform/server/internal/response" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" ) -var startTime = time.Now() +type HealthHandler struct { + pool *pgxpool.Pool + rdb *redis.Client +} -func HealthCheck(w http.ResponseWriter, r *http.Request) { - response.JSON(w, http.StatusOK, map[string]any{ - "status": "ok", - "service": "aily-portal-api", +func NewHealthHandler(pool *pgxpool.Pool, rdb *redis.Client) *HealthHandler { + return &HealthHandler{pool: pool, rdb: rdb} +} + +func (h *HealthHandler) HealthCheck(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + type dep struct { + Name string `json:"name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + } + deps := []dep{} + + // PostgreSQL + if h.pool != nil { + if err := h.pool.Ping(ctx); err != nil { + deps = append(deps, dep{Name: "postgres", Status: "down", Error: err.Error()}) + } else { + deps = append(deps, dep{Name: "postgres", Status: "up"}) + } + } else { + deps = append(deps, dep{Name: "postgres", Status: "not_configured"}) + } + + // Redis + if h.rdb != nil { + if err := h.rdb.Ping(ctx).Err(); err != nil { + deps = append(deps, dep{Name: "redis", Status: "down", Error: err.Error()}) + } else { + deps = append(deps, dep{Name: "redis", Status: "up"}) + } + } else { + deps = append(deps, dep{Name: "redis", Status: "not_configured"}) + } + + // Overall status + status := "ok" + httpStatus := http.StatusOK + for _, d := range deps { + if d.Status == "down" { + status = "degraded" + httpStatus = http.StatusServiceUnavailable + break + } + } + + // Runtime stats + var m runtime.MemStats + runtime.ReadMemStats(&m) + + response.JSON(w, httpStatus, map[string]any{ + "status": status, + "service": "govai-portal-api", "uptime": time.Since(startTime).String(), "go": runtime.Version(), + "memory": map[string]any{ + " Alloc": m.Alloc / 1024 / 1024, + "Sys": m.Sys / 1024 / 1024, + "NumGC": m.NumGC, + "Goroutine": runtime.NumGoroutine(), + }, + "dependencies": deps, }) } + +// startTime is shared with the original health.go init block. +var startTime = time.Now() \ No newline at end of file diff --git a/server/internal/handler/knowledge.go b/server/internal/handler/knowledge.go index 60f43d0..74bf05d 100644 --- a/server/internal/handler/knowledge.go +++ b/server/internal/handler/knowledge.go @@ -254,6 +254,12 @@ func (h *KnowledgeHandler) UploadDocument(w http.ResponseWriter, r *http.Request } defer file.Close() + // 文件安全校验 + if msg := ValidateFile(header, AllowedDocumentExtensions(), AllowedDocumentMaxSize()); msg != "" { + response.BadRequest(w, msg) + return + } + var exists bool err = h.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM knowledge_bases WHERE id = $1)`, kbID).Scan(&exists) diff --git a/server/internal/logger/logger.go b/server/internal/logger/logger.go new file mode 100644 index 0000000..b2398c9 --- /dev/null +++ b/server/internal/logger/logger.go @@ -0,0 +1,62 @@ +package logger + +import ( + "io" + "os" + "time" + + "github.com/rs/zerolog" +) + +var log zerolog.Logger + +func Init(level string, jsonFormat bool) { + var output io.Writer = os.Stdout + if !jsonFormat { + output = zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: time.RFC3339, + } + } + + lvl, err := zerolog.ParseLevel(level) + if err != nil { + lvl = zerolog.InfoLevel + } + + log = zerolog.New(output). + Level(lvl). + With(). + Timestamp(). + Caller(). + Logger() +} + +func Get() *zerolog.Logger { + return &log +} + +// Info/fatal/warn/error 等直接透传 +func Info() *zerolog.Event { + return log.Info() +} + +func Warn() *zerolog.Event { + return log.Warn() +} + +func Error() *zerolog.Event { + return log.Error() +} + +func Debug() *zerolog.Event { + return log.Debug() +} + +func Fatal() *zerolog.Event { + return log.Fatal() +} + +func Ctx(ctx interface{ Value(key interface{}) interface{} }) zerolog.Logger { + return log.With().Interface("ctx", ctx).Logger() +} \ No newline at end of file diff --git a/server/internal/metrics/metrics.go b/server/internal/metrics/metrics.go new file mode 100644 index 0000000..42658de --- /dev/null +++ b/server/internal/metrics/metrics.go @@ -0,0 +1,77 @@ +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + // HTTP requests + HTTPRequestsTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "Total number of HTTP requests", + }, + []string{"method", "path", "status"}, + ) + + HTTPRequestDuration = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_request_duration_seconds", + Help: "HTTP request latency in seconds", + Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}, + }, + []string{"method", "path"}, + ) + + // AI / LLM tokens + LLMTokensTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "ai_tokens_total", + Help: "Total number of AI tokens consumed", + }, + []string{"model", "type"}, // type: prompt | completion + ) + + LLMRequestsTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "ai_requests_total", + Help: "Total number of AI/LLM API requests", + }, + []string{"model", "status"}, + ) + + LLMRequestDuration = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "ai_request_duration_seconds", + Help: "AI/LLM API request latency in seconds", + Buckets: []float64{0.1, 0.5, 1, 2, 5, 10, 30, 60}, + }, + []string{"model"}, + ) + + // Chat / conversation + ConversationsTotal = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "conversations_total", + Help: "Total number of chat conversations created", + }, + ) + + MessagesTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "messages_total", + Help: "Total number of chat messages", + }, + []string{"role"}, // role: user | assistant + ) + + // Auth + AuthFailuresTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "auth_failures_total", + Help: "Total number of authentication failures", + }, + []string{"reason"}, + ) +) \ No newline at end of file diff --git a/server/internal/middleware/middleware_test.go b/server/internal/middleware/middleware_test.go new file mode 100644 index 0000000..1270e36 --- /dev/null +++ b/server/internal/middleware/middleware_test.go @@ -0,0 +1,132 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" +) + +func TestGetUserID(t *testing.T) { + userID := uuid.New() + ctx := context.WithValue(context.Background(), UserIDKey, userID) + if got := GetUserID(ctx); got != userID { + t.Errorf("GetUserID() = %v, want %v", got, userID) + } + + ctx = context.Background() + if got := GetUserID(ctx); got != uuid.Nil { + t.Errorf("GetUserID() from empty ctx = %v, want uuid.Nil", got) + } +} + +func TestGetRole(t *testing.T) { + ctx := context.WithValue(context.Background(), RoleKey, "admin") + if got := GetRole(ctx); got != "admin" { + t.Errorf("GetRole() = %v, want admin", got) + } + + ctx = context.Background() + if got := GetRole(ctx); got != "" { + t.Errorf("GetRole() from empty ctx = %v, want empty string", got) + } +} + +func TestRequireRole_UserRole(t *testing.T) { + mux := http.NewServeMux() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + wrapped := RequireRole("admin")(handler) + mux.Handle("/admin", wrapped) + + // user 角色 → 403 + userCtx := context.WithValue(context.Background(), RoleKey, "user") + req := httptest.NewRequest(http.MethodGet, "/admin", nil).WithContext(userCtx) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("user role: got %d, want %d", rr.Code, http.StatusForbidden) + } + + // admin 角色 → 200 + adminCtx := context.WithValue(context.Background(), RoleKey, "admin") + req = httptest.NewRequest(http.MethodGet, "/admin", nil).WithContext(adminCtx) + rr = httptest.NewRecorder() + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("admin role: got %d, want %d", rr.Code, http.StatusOK) + } + + // 无角色 → 403 + req = httptest.NewRequest(http.MethodGet, "/admin", nil) + rr = httptest.NewRecorder() + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("no role: got %d, want %d", rr.Code, http.StatusForbidden) + } +} + +func TestRequireSuperAdmin(t *testing.T) { + mux := http.NewServeMux() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + wrapped := RequireSuperAdmin(handler) + mux.Handle("/platform", wrapped) + + // super_admin → 200 + superAdminCtx := context.WithValue(context.Background(), RoleKey, "super_admin") + req := httptest.NewRequest(http.MethodGet, "/platform", nil).WithContext(superAdminCtx) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("super_admin: got %d, want %d", rr.Code, http.StatusOK) + } + + // admin → 403 + adminCtx := context.WithValue(context.Background(), RoleKey, "admin") + req = httptest.NewRequest(http.MethodGet, "/platform", nil).WithContext(adminCtx) + rr = httptest.NewRecorder() + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("admin: got %d, want %d", rr.Code, http.StatusForbidden) + } +} + +func TestAuditLog_NilPool(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/test", nil) + rr := httptest.NewRecorder() + + fn := AuditLog(nil) + var called bool + fn(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })).ServeHTTP(rr, req) + + if !called { + t.Error("AuditLog middleware did not call next handler") + } +} + +func TestRateLimit_NilRedis(t *testing.T) { + mux := http.NewServeMux() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + wrapped := RateLimit(nil, 5, 0)(handler) + mux.Handle("/test", wrapped) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code == http.StatusTooManyRequests { + t.Error("RateLimit should bypass when Redis is unavailable") + } +} \ No newline at end of file diff --git a/server/internal/middleware/ratelimit.go b/server/internal/middleware/ratelimit.go index 9f9ce35..60119ed 100644 --- a/server/internal/middleware/ratelimit.go +++ b/server/internal/middleware/ratelimit.go @@ -14,6 +14,11 @@ import ( func RateLimit(rdb *redis.Client, maxRequests int, window time.Duration) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if rdb == nil { + next.ServeHTTP(w, r) + return + } + userID := GetUserID(r.Context()) key := fmt.Sprintf("rl:%s:%s", userID.String(), r.URL.Path) diff --git a/server/internal/response/errors.go b/server/internal/response/errors.go new file mode 100644 index 0000000..d6b903d --- /dev/null +++ b/server/internal/response/errors.go @@ -0,0 +1,93 @@ +package response + +import "net/http" + +// 错误码枚举,统一业务错误定义 +type ErrCode int + +const ( + // 通用错误 (0xxxx) + ErrCodeSuccess ErrCode = 0 + ErrCodeBadRequest ErrCode = 40001 + ErrCodeUnauthorized ErrCode = 40101 + ErrCodeForbidden ErrCode = 40301 + ErrCodeNotFound ErrCode = 40401 + ErrCodeRequestTimeout ErrCode = 40801 + ErrCodeTooManyRequests ErrCode = 42901 + ErrCodeInternalError ErrCode = 50001 + ErrCodeServiceUnavailable ErrCode = 50301 + ErrCodeGatewayTimeout ErrCode = 50401 + + // 业务错误 (6xxxx) + ErrCodeInvalidToken ErrCode = 40102 + ErrCodeTokenExpired ErrCode = 40103 + ErrCodeUserDisabled ErrCode = 40104 + ErrCodeOrgNotFound ErrCode = 40402 + ErrCodeAppNotFound ErrCode = 40403 + ErrCodeKnowledgeNotFound ErrCode = 40404 + ErrCodeInsufficientQuota ErrCode = 42902 + ErrCodeFileTooLarge ErrCode = 40002 + ErrCodeInvalidFileType ErrCode = 40003 +) + +var httpStatusMap = map[ErrCode]int{ + ErrCodeSuccess: http.StatusOK, + ErrCodeBadRequest: http.StatusBadRequest, + ErrCodeUnauthorized: http.StatusUnauthorized, + ErrCodeForbidden: http.StatusForbidden, + ErrCodeNotFound: http.StatusNotFound, + ErrCodeRequestTimeout: http.StatusRequestTimeout, + ErrCodeTooManyRequests: http.StatusTooManyRequests, + ErrCodeInternalError: http.StatusInternalServerError, + ErrCodeServiceUnavailable: http.StatusServiceUnavailable, + ErrCodeGatewayTimeout: http.StatusGatewayTimeout, + ErrCodeInvalidToken: http.StatusUnauthorized, + ErrCodeTokenExpired: http.StatusUnauthorized, + ErrCodeUserDisabled: http.StatusUnauthorized, + ErrCodeOrgNotFound: http.StatusNotFound, + ErrCodeAppNotFound: http.StatusNotFound, + ErrCodeKnowledgeNotFound: http.StatusNotFound, + ErrCodeInsufficientQuota: http.StatusTooManyRequests, + ErrCodeFileTooLarge: http.StatusBadRequest, + ErrCodeInvalidFileType: http.StatusBadRequest, +} + +func (e ErrCode) Status() int { + if s, ok := httpStatusMap[e]; ok { + return s + } + return http.StatusInternalServerError +} + +func (e ErrCode) Code() int { + return int(e) +} + +func (e ErrCode) Error() string { + return e.String() +} + +func (e ErrCode) String() string { + switch e { + case ErrCodeSuccess: return "成功" + case ErrCodeBadRequest: return "请求参数有误" + case ErrCodeUnauthorized: return "未授权" + case ErrCodeForbidden: return "无权限" + case ErrCodeNotFound: return "资源不存在" + case ErrCodeRequestTimeout: return "请求超时" + case ErrCodeTooManyRequests: return "请求过于频繁" + case ErrCodeInternalError: return "服务器内部错误" + case ErrCodeServiceUnavailable: return "服务不可用" + case ErrCodeGatewayTimeout: return "网关超时" + case ErrCodeInvalidToken: return "无效的认证令牌" + case ErrCodeTokenExpired: return "认证令牌已过期" + case ErrCodeUserDisabled: return "用户已被禁用" + case ErrCodeOrgNotFound: return "机构不存在" + case ErrCodeAppNotFound: return "应用不存在" + case ErrCodeKnowledgeNotFound: return "知识库不存在" + case ErrCodeInsufficientQuota: return "配额不足" + case ErrCodeFileTooLarge: return "文件超出大小限制" + case ErrCodeInvalidFileType: return "不支持的文件类型" + default: return "未知错误" + } +} \ No newline at end of file diff --git a/server/internal/response/response_test.go b/server/internal/response/response_test.go new file mode 100644 index 0000000..d788d9d --- /dev/null +++ b/server/internal/response/response_test.go @@ -0,0 +1,80 @@ +package response + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestJSON(t *testing.T) { + w := httptest.NewRecorder() + data := map[string]string{"key": "value"} + JSON(w, http.StatusOK, data) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + if ct := w.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %s, want application/json", ct) + } + + var resp APIResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if resp.Code != 0 { + t.Errorf("code = %d, want 0", resp.Code) + } + if resp.Message != "success" { + t.Errorf("message = %s, want success", resp.Message) + } +} + +func TestBadRequest(t *testing.T) { + w := httptest.NewRecorder() + BadRequest(w, "参数错误") + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest) + } +} + +func TestUnauthorized(t *testing.T) { + w := httptest.NewRecorder() + Unauthorized(w, "未登录") + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +func TestForbidden(t *testing.T) { + w := httptest.NewRecorder() + Forbidden(w, "无权限") + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden) + } +} + +func TestNotFound(t *testing.T) { + w := httptest.NewRecorder() + NotFound(w, "资源不存在") + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestInternalError(t *testing.T) { + w := httptest.NewRecorder() + InternalError(w, "内部错误") + if w.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want %d", w.Code, http.StatusInternalServerError) + } +} + +func TestTooManyRequests(t *testing.T) { + w := httptest.NewRecorder() + TooManyRequests(w, "过于频繁") + if w.Code != http.StatusTooManyRequests { + t.Errorf("status = %d, want %d", w.Code, http.StatusTooManyRequests) + } +} \ No newline at end of file diff --git a/server/pkg/tenant/tenant.go b/server/pkg/tenant/tenant.go new file mode 100644 index 0000000..9320fab --- /dev/null +++ b/server/pkg/tenant/tenant.go @@ -0,0 +1,67 @@ +package tenant + +import ( + "context" + "database/sql" + + "github.com/google/uuid" +) + +// contextKey is the type for context values used by this package. +type contextKey string + +const ( + orgIDKey contextKey = "org_id" + userIDKey contextKey = "user_id" + roleKey contextKey = "role" +) + +// WithOrgID stores the organization ID in context. +func WithOrgID(ctx context.Context, orgID string) context.Context { + return context.WithValue(ctx, orgIDKey, orgID) +} + +// GetOrgID retrieves the organization ID from context. +// Returns empty string if not set. +func GetOrgID(ctx context.Context) string { + if v := ctx.Value(orgIDKey); v != nil { + return v.(string) + } + return "" +} + +// WithUserID stores the user ID in context. +func WithUserID(ctx context.Context, userID uuid.UUID) context.Context { + return context.WithValue(ctx, userIDKey, userID) +} + +// GetUserID retrieves the user ID from context. +func GetUserID(ctx context.Context) uuid.UUID { + if v := ctx.Value(userIDKey); v != nil { + return v.(uuid.UUID) + } + return uuid.Nil +} + +// GetUserOrgID queries the database for the current user's org_id. +// This is the standard way to get the caller's organization. +func GetUserOrgID(ctx context.Context, pool interface { + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +}, userID uuid.UUID) (string, error) { + var orgID string + err := pool.QueryRowContext(ctx, + `SELECT COALESCE(org_id::text, '') FROM users WHERE id = $1`, + userID, + ).Scan(&orgID) + return orgID, err +} + +// IsSuperAdmin checks if the given role is platform super admin. +func IsSuperAdmin(role string) bool { + return role == "super_admin" +} + +// IsAdmin checks if the given role is at least org-level admin. +func IsAdmin(role string) bool { + return role == "admin" || role == "super_admin" +} \ No newline at end of file