c949204662
借鉴 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 应用迁移并烟测。
384 lines
11 KiB
Go
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
|
|
}
|