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