feat: 系统优化 - ESLint、Tailwind、前端健壮性、后端工程化、运维可观测性
- 前端: 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/
This commit is contained in:
+158
@@ -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、文件上传校验 |
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"singleQuote": false,
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"plugins": []
|
||||
}
|
||||
@@ -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;
|
||||
Generated
+219
-79
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4">
|
||||
<div className="flex flex-col items-center text-center max-w-md">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 mb-6">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">页面出现异常</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
很抱歉,系统遇到了意外错误。请尝试刷新页面,如问题持续存在请联系管理员。
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-muted-foreground/60 mb-4 font-mono">
|
||||
错误标识:{error.digest}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={reset} className="gap-2">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重试
|
||||
</Button>
|
||||
<a href="/store">
|
||||
<Button className="gap-2">
|
||||
<Home className="h-4 w-4" />
|
||||
返回首页
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body>
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
||||
<div className="flex flex-col items-center text-center max-w-md">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 mb-6">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">页面出现异常</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
很抱歉,系统遇到了意外错误。请尝试刷新页面,如问题持续存在请联系管理员。
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-muted-foreground/60 mb-4 font-mono">
|
||||
错误标识:{error.digest}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={reset} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
重试
|
||||
</Button>
|
||||
<Link href="/store">
|
||||
<Button className="gap-2">
|
||||
<Home className="h-4 w-4" />
|
||||
返回首页
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<html lang="zh-CN" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<Providers>{children}</Providers>
|
||||
<ErrorBoundary>
|
||||
<Providers>{children}</Providers>
|
||||
</ErrorBoundary>
|
||||
<Toaster position="top-center" richColors />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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<Props, State> {
|
||||
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 (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4">
|
||||
<div className="flex flex-col items-center text-center max-w-md">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 mb-6">
|
||||
<AlertTriangle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">页面出现异常</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
很抱歉,系统遇到了意外错误。请尝试重试,如问题持续存在请联系管理员。
|
||||
</p>
|
||||
{this.state.errorId && (
|
||||
<p className="text-xs text-muted-foreground/60 mb-4 font-mono">
|
||||
错误标识:{this.state.errorId}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={this.reset} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
重试
|
||||
</Button>
|
||||
<Link href="/store">
|
||||
<Button variant="secondary" className="gap-2">
|
||||
<Home className="h-4 w-4" />
|
||||
返回首页
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<QueryClient>(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 (
|
||||
|
||||
@@ -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;
|
||||
+11
-11
@@ -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:
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"},
|
||||
)
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 "未知错误"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user