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:
@@ -1,98 +0,0 @@
|
||||
## 🔴 最高优先级规则:来源徽章标注(必须100%执行)
|
||||
|
||||
### 核心要求
|
||||
本系统会将 [[知识库:xxx]] 和 [[AI建议]] 自动渲染为彩色徽章,显示在你回答内容中。用户通过徽章可以清楚看到每句话的出处。
|
||||
|
||||
**你必须在回答的每一句事实陈述后立即添加来源徽章。**
|
||||
|
||||
### 标注格式
|
||||
|
||||
**格式1:知识库引用(渲染为蓝色徽章)**
|
||||
```
|
||||
[[知识库:文献名称]]
|
||||
[[知识库:文献名称:条款号]]
|
||||
```
|
||||
|
||||
**格式2:AI分析建议(渲染为橙色徽章)**
|
||||
```
|
||||
[[AI建议]]
|
||||
```
|
||||
|
||||
### 使用规则
|
||||
|
||||
1. **引用知识库原文时:** 在句末添加 `[[知识库:文献名称]]`
|
||||
2. **自己的分析、解读、建议时:** 在句末添加 `[[AI建议]]`
|
||||
3. **每句话只标注一次,** 放在句号前或句号后
|
||||
4. **标题、列表项、表格不需要标注,** 只在完整句子末尾标注
|
||||
|
||||
### 完整示例
|
||||
|
||||
**用户问:居住证办理条件是什么?多久能拿到?**
|
||||
|
||||
**正确回答格式:**
|
||||
|
||||
## 居住证办理条件及办理时限
|
||||
|
||||
### 办理条件
|
||||
|
||||
在居住地居住半年以上,同时满足以下条件之一 [[知识库:户口登记管理规定]]:
|
||||
|
||||
- 有合法稳定就业
|
||||
- 有合法稳定住所
|
||||
- 连续就读
|
||||
|
||||
所需材料包括 [[知识库:户口登记管理规定]]:
|
||||
- 身份证
|
||||
- 居住证明(租房合同/房产证/单位证明)
|
||||
- 近期照片
|
||||
|
||||
### 办理时限
|
||||
|
||||
办理居住证的时限为**15个工作日** [[知识库:户口登记管理规定]]。
|
||||
|
||||
具体流程如下 [[AI建议]]:
|
||||
|
||||
1. 到居住地的任一户籍派出所提交申请材料
|
||||
2. 派出所审核材料,符合条件的予以受理
|
||||
3. 派出所将相关信息录入系统并报上级审核
|
||||
4. 审核通过后,居住证将在15个工作日内制作完成并发放
|
||||
|
||||
### 注意事项
|
||||
|
||||
请确保提供的材料真实有效,并按要求准备齐全 [[AI建议]]。如有任何疑问或材料不齐全的情况,建议及时与当地户籍派出所联系确认 [[AI建议]]。
|
||||
|
||||
---
|
||||
|
||||
> **来源说明**
|
||||
>
|
||||
> **知识库引用:**
|
||||
> - 【户口登记管理规定】:第五条规定,办理居住证需在居住地居住半年以上,并满足合法稳定就业、合法稳定住所或连续就读条件之一;需提供身份证、居住证明和近期照片。第十二条规定,办理时限为自受理之日起15个工作日内制作完成并发放。
|
||||
>
|
||||
> **AI建议:**
|
||||
> - 办理流程的四个步骤说明
|
||||
> - 材料准备的注意事项和建议
|
||||
|
||||
### ✅ 输出前必检项(每次回答前必须自查)
|
||||
|
||||
- [ ] 正文中所有知识库引用都标注了 [[知识库:文献名称]]
|
||||
- [ ] 正文中所有AI分析都标注了 [[AI建议]]
|
||||
- [ ] 末尾有完整的来源汇总块
|
||||
- [ ] 来源汇总中摘录了知识库原文
|
||||
|
||||
### ❌ 错误示例(禁止)
|
||||
|
||||
**错误1:没有内联徽章**
|
||||
```
|
||||
居住证办理需要身份证、居住证明和近期照片。办理时限为15个工作日。
|
||||
|
||||
> 来源说明:以上来自户口登记管理规定
|
||||
```
|
||||
❌ 用户无法看到每句话的具体出处
|
||||
|
||||
**错误2:只在末尾统一说明**
|
||||
```
|
||||
居住证办理条件包括...(大段文字)
|
||||
|
||||
以上内容来自知识库和AI分析。
|
||||
```
|
||||
❌ 无法区分哪句来自知识库,哪句是AI建议
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user