Files
GovAI/server/internal/handler/store.go
T
freedakgmail c949204662 feat(govai): 0617 优化首批 — 安全/私有化/深度研究/服务层/可观测性
借鉴 odysseus 的能力设计,全程净室实现、零 AGPL 代码、不引入 AGPL 依赖。

T1 提示注入防护: pkg/promptguard 包裹外部/知识库内容为不可信数据,buildMessages 移出 system 指令区。 T2 安全 CI: .github/workflows(ci+security: govulncheck/gitleaks/actionlint/hadolint/trivy)+dependabot+.hadolint.yaml;go.mod 加 toolchain go1.25.11 修复 20 个 stdlib CVE。 T3 管理员 2FA: 迁移 000016 + RFC6238 TOTP/备份码(pkg/auth, 零依赖) + 登录流程集成(后端)。 T4 本地模型: LLM/embedding 支持本地 vLLM/Ollama(OpenAI 兼容, 鉴权头条件发送, NoAuth) + docs/local-deploy.md。 T6 深度研究: 迁移 000017 + Python research-worker(净室多步流水线, 检索避开 SearXNG) + Go research 服务/handler/路由。 T7 service 层: 新增 internal/service/{research,twofa}, 2FA 业务逻辑从胖 handler 下沉, 接口注入可单测。 T10 缓存/可观测性: internal/cache(Redis+内存, 优雅降级) 接入 store 热点列表; Prometheus 指标+/metrics; docs/openapi.yaml。 验证: go build/vet/test ./... 全绿(8 包); research-worker 12 单测过; 真实 PG 应用迁移并烟测。
2026-06-17 17:52:47 +08:00

384 lines
11 KiB
Go

package handler
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/enterprise-ai-platform/server/internal/cache"
"github.com/enterprise-ai-platform/server/internal/middleware"
"github.com/enterprise-ai-platform/server/internal/response"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type StoreHandler struct {
pool *pgxpool.Pool
cache cache.Cache
}
func NewStoreHandler(pool *pgxpool.Pool, c cache.Cache) *StoreHandler {
if c == nil {
c = cache.NewMemory()
}
return &StoreHandler{pool: pool, cache: c}
}
// storeListTTL 热点只读列表的缓存时效(短 TTL,避免显式失效的复杂度)。
const storeListTTL = 60 * time.Second
func (h *StoreHandler) ListCategories(w http.ResponseWriter, r *http.Request) {
orgID := r.URL.Query().Get("org_id")
cacheKey := "store:categories:" + orgID
var cached []map[string]any
if h.cache.GetJSON(r.Context(), cacheKey, &cached) {
response.JSON(w, http.StatusOK, cached)
return
}
query := `SELECT c.id, c.name, c.slug, c.icon, c.description, c.sort_order,
COALESCE((SELECT COUNT(*) FROM applications a WHERE a.category_id = c.id AND a.status = 'approved'), 0) AS app_count
FROM categories c WHERE c.status = 'active'`
var args []any
if orgID != "" {
query += ` AND (c.org_id = $1 OR c.org_id IS NULL)`
args = append(args, orgID)
}
query += ` ORDER BY c.sort_order ASC`
rows, err := h.pool.Query(r.Context(), query, args...)
if err != nil {
response.InternalError(w, "查询分类失败")
return
}
defer rows.Close()
var cats []map[string]any
for rows.Next() {
var id, name, slug string
var icon, desc *string
var sortOrder int
var appCount int
if err := rows.Scan(&id, &name, &slug, &icon, &desc, &sortOrder, &appCount); err != nil {
continue
}
cats = append(cats, map[string]any{
"id": id, "name": name, "slug": slug,
"icon": icon, "description": desc, "sort_order": sortOrder,
"app_count": appCount,
})
}
if cats == nil {
cats = []map[string]any{}
}
h.cache.SetJSON(r.Context(), cacheKey, cats, storeListTTL)
response.JSON(w, http.StatusOK, cats)
}
func (h *StoreHandler) ListApps(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
if page < 1 {
page = 1
}
pageSize, _ := strconv.Atoi(q.Get("page_size"))
if pageSize < 1 || pageSize > 50 {
pageSize = 20
}
offset := (page - 1) * pageSize
search := q.Get("q")
category := q.Get("category")
sort := q.Get("sort")
orgFilter := q.Get("org_id")
if sort == "" {
sort = "popular"
}
query := `
SELECT a.id, a.name, a.slug, a.description, a.icon_url,
c.name as category_name, c.slug as category_slug,
u.name as creator_name,
a.usage_count, a.favorite_count, a.avg_rating, a.rating_count,
a.dify_app_type, a.welcome_message, a.published_at::text
FROM applications a
LEFT JOIN categories c ON a.category_id = c.id
LEFT JOIN users u ON a.creator_id = u.id
WHERE a.status = 'approved' AND a.visibility = 'public'`
args := []any{}
argIdx := 1
// 按机构过滤:显示指定机构的应用 + 无机构归属的全局应用
if orgFilter != "" {
query += ` AND (a.org_id = $` + strconv.Itoa(argIdx) + ` OR a.org_id IS NULL)`
args = append(args, orgFilter)
argIdx++
}
if search != "" {
query += ` AND to_tsvector('simple', a.name || ' ' || COALESCE(a.description, ''))
@@ plainto_tsquery('simple', $` + strconv.Itoa(argIdx) + `)`
args = append(args, search)
argIdx++
}
if category != "" {
query += ` AND c.slug = $` + strconv.Itoa(argIdx)
args = append(args, category)
argIdx++
}
switch sort {
case "rating":
query += ` ORDER BY a.avg_rating DESC, a.usage_count DESC`
case "latest":
query += ` ORDER BY a.published_at DESC NULLS LAST`
default:
query += ` ORDER BY a.usage_count DESC`
}
query += ` LIMIT $` + strconv.Itoa(argIdx) + ` OFFSET $` + strconv.Itoa(argIdx+1)
args = append(args, pageSize, offset)
rows, err := h.pool.Query(r.Context(), query, args...)
if err != nil {
response.InternalError(w, "查询应用失败")
return
}
defer rows.Close()
var apps []map[string]any
for rows.Next() {
var (
id, name, slug string
desc, iconURL *string
catName, catSlug *string
creatorName *string
usageCount int64
favCount, ratingCt int
avgRating float32
difyType, welcome *string
publishedAt *string
)
if err := rows.Scan(&id, &name, &slug, &desc, &iconURL,
&catName, &catSlug, &creatorName,
&usageCount, &favCount, &avgRating, &ratingCt,
&difyType, &welcome, &publishedAt); err != nil {
continue
}
apps = append(apps, map[string]any{
"id": id, "name": name, "slug": slug,
"description": desc, "icon_url": iconURL,
"category_name": catName, "category_slug": catSlug,
"creator_name": creatorName,
"usage_count": usageCount, "favorite_count": favCount,
"avg_rating": avgRating, "rating_count": ratingCt,
"dify_app_type": difyType, "welcome_message": welcome,
"published_at": publishedAt,
})
}
if apps == nil {
apps = []map[string]any{}
}
response.JSON(w, http.StatusOK, map[string]any{
"items": apps,
"page": page,
"page_size": pageSize,
})
}
func (h *StoreHandler) GetApp(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
var (
id, name, appSlug string
desc, longDesc *string
iconURL *string
catName, catSlug *string
creatorName *string
usageCount int64
favCount, ratingCt int
avgRating float32
difyType, welcome *string
suggestedPrompts *string
appConfig *string
publishedAt *string
version string
)
err := h.pool.QueryRow(r.Context(), `
SELECT a.id, a.name, a.slug, a.description, a.long_description, a.icon_url,
c.name, c.slug, u.name,
a.usage_count, a.favorite_count, a.avg_rating, a.rating_count,
a.dify_app_type, a.welcome_message, a.suggested_prompts::text,
a.app_config::text,
a.published_at::text, a.version
FROM applications a
LEFT JOIN categories c ON a.category_id = c.id
LEFT JOIN users u ON a.creator_id = u.id
WHERE a.slug = $1 AND a.status = 'approved'`, slug,
).Scan(&id, &name, &appSlug, &desc, &longDesc, &iconURL,
&catName, &catSlug, &creatorName,
&usageCount, &favCount, &avgRating, &ratingCt,
&difyType, &welcome, &suggestedPrompts,
&appConfig,
&publishedAt, &version)
if err != nil {
response.NotFound(w, "应用不存在")
return
}
// Check if user favorited this app
isFavorited := false
userID := middleware.GetUserID(r.Context())
if userID.String() != "00000000-0000-0000-0000-000000000000" {
_ = h.pool.QueryRow(r.Context(),
`SELECT EXISTS(SELECT 1 FROM app_favorites WHERE user_id = $1 AND app_id = $2)`,
userID, id).Scan(&isFavorited)
}
// Parse app_config as JSON if present
var configData any
if appConfig != nil {
_ = json.Unmarshal([]byte(*appConfig), &configData)
}
response.JSON(w, http.StatusOK, map[string]any{
"id": id, "name": name, "slug": appSlug,
"description": desc, "long_description": longDesc, "icon_url": iconURL,
"category_name": catName, "category_slug": catSlug,
"creator_name": creatorName,
"usage_count": usageCount, "favorite_count": favCount,
"avg_rating": avgRating, "rating_count": ratingCt,
"dify_app_type": difyType, "welcome_message": welcome,
"suggested_prompts": suggestedPrompts,
"app_config": configData,
"published_at": publishedAt, "version": version,
"is_favorited": isFavorited,
})
}
func (h *StoreHandler) Featured(w http.ResponseWriter, r *http.Request) {
orgID := r.URL.Query().Get("org_id")
cacheKey := "store:featured:" + orgID
var cached []map[string]any
if h.cache.GetJSON(r.Context(), cacheKey, &cached) {
response.JSON(w, http.StatusOK, cached)
return
}
query := `
SELECT a.id, a.name, a.slug, a.description, a.icon_url,
c.name as category_name, c.slug as category_slug,
a.usage_count, a.avg_rating, a.rating_count, a.dify_app_type
FROM applications a
LEFT JOIN categories c ON a.category_id = c.id
WHERE a.is_featured = true AND a.status = 'approved' AND a.visibility = 'public'`
var args []any
if orgID != "" {
query += ` AND (a.org_id = $1 OR a.org_id IS NULL)`
args = append(args, orgID)
}
query += ` ORDER BY a.usage_count DESC LIMIT 4`
rows, err := h.pool.Query(r.Context(), query, args...)
if err != nil {
response.InternalError(w, "查询精选应用失败")
return
}
defer rows.Close()
apps := scanAppList(rows)
h.cache.SetJSON(r.Context(), cacheKey, apps, storeListTTL)
response.JSON(w, http.StatusOK, apps)
}
func (h *StoreHandler) Rankings(w http.ResponseWriter, r *http.Request) {
orgID := r.URL.Query().Get("org_id")
cacheKey := "store:rankings:" + orgID
var cached []map[string]any
if h.cache.GetJSON(r.Context(), cacheKey, &cached) {
response.JSON(w, http.StatusOK, cached)
return
}
query := `
SELECT a.id, a.name, a.slug, a.description, a.icon_url,
c.name as category_name, c.slug as category_slug,
a.usage_count, a.avg_rating, a.rating_count, a.dify_app_type
FROM applications a
LEFT JOIN categories c ON a.category_id = c.id
WHERE a.status = 'approved' AND a.visibility = 'public'`
var args []any
if orgID != "" {
query += ` AND (a.org_id = $1 OR a.org_id IS NULL)`
args = append(args, orgID)
}
query += ` ORDER BY a.usage_count DESC LIMIT 50`
rows, err := h.pool.Query(r.Context(), query, args...)
if err != nil {
response.InternalError(w, "查询排行榜失败")
return
}
defer rows.Close()
apps := scanAppList(rows)
h.cache.SetJSON(r.Context(), cacheKey, apps, storeListTTL)
response.JSON(w, http.StatusOK, apps)
}
func (h *StoreHandler) Recent(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
rows, err := h.pool.Query(r.Context(), `
SELECT DISTINCT ON (a.id) a.id, a.name, a.slug, a.description, a.icon_url,
c.name as category_name, c.slug as category_slug,
a.usage_count, a.avg_rating, a.rating_count, a.dify_app_type
FROM app_usage_logs l
JOIN applications a ON l.app_id = a.id
LEFT JOIN categories c ON a.category_id = c.id
WHERE l.user_id = $1
ORDER BY a.id, l.created_at DESC
LIMIT 10`, userID)
if err != nil {
response.InternalError(w, "查询最近使用失败")
return
}
defer rows.Close()
apps := scanAppList(rows)
response.JSON(w, http.StatusOK, apps)
}
func scanAppList(rows interface {
Next() bool
Scan(dest ...any) error
}) []map[string]any {
var apps []map[string]any
for rows.Next() {
var (
id, name, slug string
desc, iconURL *string
catName, catSlug *string
usageCount int64
avgRating float32
ratingCt int
difyType *string
)
if err := rows.Scan(&id, &name, &slug, &desc, &iconURL,
&catName, &catSlug, &usageCount, &avgRating, &ratingCt, &difyType); err != nil {
continue
}
apps = append(apps, map[string]any{
"id": id, "name": name, "slug": slug,
"description": desc, "icon_url": iconURL,
"category_name": catName, "category_slug": catSlug,
"usage_count": usageCount, "avg_rating": avgRating, "rating_count": ratingCt,
"dify_app_type": difyType,
})
}
if apps == nil {
apps = []map[string]any{}
}
return apps
}