618399f86d
- Replace single Load() with a watching Store that re-reads config files
(and config.local.yaml) and fans out a ReloadEvent with a per-field diff
so consumers can decide whether the change is hot-applicable or requires
a process restart.
- Wrap llm, retrieval, vector store, and object storage clients in
Reloadable* shells so the bootstrap can swap their underlying impls when
config changes without re-instantiating handlers.
- Make jwt.Manager and ops TokenIssuer mutable under a lock so secrets and
TTLs can be rotated live; thread default plan code through a setter on
the ops AdminUserService.
- Wire ConfigStore through bootstrap and every cmd/main.go, scheduler /
worker / tenant-api / ops-api start the watcher; services and handlers
take a config.Provider so they always read current values for things
like generation.stream_enabled, scheduler dispatch, retrieval, etc.
- Switch shared/config decoding off viper to a Kratos-derived runtime
package so env placeholders (\${VAR:default}) resolve consistently and
the same source machinery powers both the loader and the watcher.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
660 lines
14 KiB
Go
660 lines
14 KiB
Go
package transport
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
"github.com/geo-platform/tenant-api/internal/tenant/app"
|
|
)
|
|
|
|
type KolManageHandler struct {
|
|
profileSvc *app.KolProfileService
|
|
pkgSvc *app.KolPackageService
|
|
promptSvc *app.KolPromptService
|
|
assistSvc *app.KolAssistService
|
|
assets *AssetHandler
|
|
}
|
|
|
|
type kolAssistStreamEvent struct {
|
|
Mode string `json:"mode,omitempty"`
|
|
Status string `json:"status"`
|
|
Delta string `json:"delta,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
Schema *app.KolSchemaJSON `json:"schema,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
Done bool `json:"done"`
|
|
}
|
|
|
|
func NewKolManageHandler(a *bootstrap.App, assets *AssetHandler) *KolManageHandler {
|
|
profileSvc := app.NewKolProfileService(a.KolProfiles).WithObjectStorage(a.ObjectStorage)
|
|
|
|
return &KolManageHandler{
|
|
profileSvc: profileSvc,
|
|
pkgSvc: app.NewKolPackageService(profileSvc, a.KolPackages, a.KolPrompts, a.KolSubscriptions).WithCache(a.Cache),
|
|
promptSvc: app.NewKolPromptService(a.DB, profileSvc, a.KolPackages, a.KolPrompts, a.KolSubscriptions, a.KolPromptAsset).WithCache(a.Cache),
|
|
assistSvc: app.NewKolAssistService(a.DB, profileSvc, a.KolAssists, a.LLM, a.RabbitMQ, a.Config().LLM, a.Logger).WithCache(a.Cache).WithConfigProvider(a.ConfigStore),
|
|
assets: assets,
|
|
}
|
|
}
|
|
|
|
func (h *KolManageHandler) GetProfile(c *gin.Context) {
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
profile, err := h.profileSvc.RequireActiveKol(c.Request.Context(), actor)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, app.NewKolProfileResponse(profile))
|
|
}
|
|
|
|
type updateKolProfileRequest struct {
|
|
DisplayName string `json:"display_name"`
|
|
Bio *string `json:"bio"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
}
|
|
|
|
func (h *KolManageHandler) UpdateProfile(c *gin.Context) {
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req updateKolProfileRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
|
return
|
|
}
|
|
|
|
profile, err := h.profileSvc.UpdateProfile(c.Request.Context(), actor, app.UpdateKolProfileServiceInput{
|
|
DisplayName: req.DisplayName,
|
|
Bio: req.Bio,
|
|
AvatarURL: req.AvatarURL,
|
|
})
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, app.NewKolProfileResponse(profile))
|
|
}
|
|
|
|
func (h *KolManageHandler) UploadAvatar(c *gin.Context) {
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
fileHeader, err := c.FormFile("file")
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40004, "kol_avatar_invalid", "avatar file is required"))
|
|
return
|
|
}
|
|
|
|
f, err := fileHeader.Open()
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40004, "kol_avatar_invalid", "avatar file cannot be opened"))
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
content, err := io.ReadAll(f)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40004, "kol_avatar_invalid", "avatar file cannot be read"))
|
|
return
|
|
}
|
|
|
|
result, err := h.profileSvc.UploadAvatar(c.Request.Context(), actor, fileHeader.Filename, content)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
result.URL = h.assets.BuildArticleImageURL(result.ObjectKey)
|
|
|
|
response.SuccessWithStatus(c, http.StatusCreated, result)
|
|
}
|
|
|
|
func (h *KolManageHandler) ListPackages(c *gin.Context) {
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
data, err := h.pkgSvc.List(c.Request.Context(), actor)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *KolManageHandler) CreatePackage(c *gin.Context) {
|
|
var req app.CreateKolPackageInput
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
data, err := h.pkgSvc.Create(c.Request.Context(), actor, req)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *KolManageHandler) UpdatePackage(c *gin.Context) {
|
|
id, ok := parseInt64Param(c, "id", "package id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req app.UpdateKolPackageInput
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
data, err := h.pkgSvc.Update(c.Request.Context(), actor, id, req)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *KolManageHandler) DeletePackage(c *gin.Context) {
|
|
id, ok := parseInt64Param(c, "id", "package id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if err := h.pkgSvc.Delete(c.Request.Context(), actor, id); err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, nil)
|
|
}
|
|
|
|
func (h *KolManageHandler) PublishPackage(c *gin.Context) {
|
|
id, ok := parseInt64Param(c, "id", "package id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
data, err := h.pkgSvc.Publish(c.Request.Context(), actor, id)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *KolManageHandler) ArchivePackage(c *gin.Context) {
|
|
id, ok := parseInt64Param(c, "id", "package id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
data, err := h.pkgSvc.Archive(c.Request.Context(), actor, id)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *KolManageHandler) SelfSubscribePackage(c *gin.Context) {
|
|
id, ok := parseInt64Param(c, "id", "package id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
data, err := h.pkgSvc.SelfSubscribe(c.Request.Context(), actor, id)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *KolManageHandler) CancelSelfSubscription(c *gin.Context) {
|
|
id, ok := parseInt64Param(c, "id", "package id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
data, err := h.pkgSvc.CancelSelfSubscription(c.Request.Context(), actor, id)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *KolManageHandler) ListPrompts(c *gin.Context) {
|
|
packageID, ok := parseInt64Param(c, "id", "package id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
data, err := h.promptSvc.ListByPackage(c.Request.Context(), actor, packageID)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *KolManageHandler) CreatePrompt(c *gin.Context) {
|
|
packageID, ok := parseInt64Param(c, "id", "package id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req app.CreatePromptInput
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
|
return
|
|
}
|
|
req.PackageID = packageID
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
data, err := h.promptSvc.CreatePrompt(c.Request.Context(), actor, req)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *KolManageHandler) GetPromptLatest(c *gin.Context) {
|
|
promptID, ok := parseInt64Param(c, "id", "prompt id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
data, err := h.promptSvc.GetForOwner(c.Request.Context(), actor, promptID)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *KolManageHandler) UpdatePromptMetadata(c *gin.Context) {
|
|
promptID, ok := parseInt64Param(c, "id", "prompt id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req app.UpdateKolPromptInput
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
data, err := h.promptSvc.UpdateMetadata(c.Request.Context(), actor, promptID, req)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *KolManageHandler) DeletePrompt(c *gin.Context) {
|
|
promptID, ok := parseInt64Param(c, "id", "prompt id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if err := h.promptSvc.Delete(c.Request.Context(), actor, promptID); err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, nil)
|
|
}
|
|
|
|
func (h *KolManageHandler) SavePrompt(c *gin.Context) {
|
|
promptID, ok := parseInt64Param(c, "id", "prompt id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req app.PublishPromptInput
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
data, err := h.promptSvc.Save(c.Request.Context(), actor, promptID, req)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *KolManageHandler) PublishPrompt(c *gin.Context) {
|
|
promptID, ok := parseInt64Param(c, "id", "prompt id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req app.PublishPromptInput
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
data, err := h.promptSvc.Publish(c.Request.Context(), actor, promptID, req)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *KolManageHandler) ActivatePrompt(c *gin.Context) {
|
|
promptID, ok := parseInt64Param(c, "id", "prompt id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
data, err := h.promptSvc.Activate(c.Request.Context(), actor, promptID)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *KolManageHandler) ArchivePrompt(c *gin.Context) {
|
|
promptID, ok := parseInt64Param(c, "id", "prompt id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
data, err := h.promptSvc.Archive(c.Request.Context(), actor, promptID)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func (h *KolManageHandler) AssistSubmit(c *gin.Context) {
|
|
var req app.AssistRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
taskID, err := h.assistSvc.Submit(c.Request.Context(), actor, req)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, app.KolAssistSubmitResponse{TaskID: taskID})
|
|
}
|
|
|
|
func (h *KolManageHandler) AssistStream(c *gin.Context) {
|
|
var req app.AssistRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "text/event-stream")
|
|
c.Header("Cache-Control", "no-cache")
|
|
c.Header("Connection", "keep-alive")
|
|
c.Header("X-Accel-Buffering", "no")
|
|
|
|
flusher, ok := c.Writer.(http.Flusher)
|
|
if !ok {
|
|
response.Error(c, response.ErrInternal(50011, "stream_unsupported", "response writer does not support streaming"))
|
|
return
|
|
}
|
|
|
|
if err := writeSSE(c, "start", kolAssistStreamEvent{
|
|
Mode: req.Mode,
|
|
Status: "generating",
|
|
Done: false,
|
|
}); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
|
|
var streamed strings.Builder
|
|
var streamWriteErr error
|
|
|
|
result, model, err := h.assistSvc.Run(c.Request.Context(), actor, req, func(delta string) {
|
|
if delta == "" || streamWriteErr != nil {
|
|
return
|
|
}
|
|
|
|
streamed.WriteString(delta)
|
|
if writeErr := writeSSE(c, "delta", kolAssistStreamEvent{
|
|
Mode: req.Mode,
|
|
Status: "generating",
|
|
Delta: delta,
|
|
Content: streamed.String(),
|
|
Done: false,
|
|
}); writeErr != nil {
|
|
streamWriteErr = writeErr
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
})
|
|
if streamWriteErr != nil {
|
|
return
|
|
}
|
|
if err != nil {
|
|
if errors.Is(c.Request.Context().Err(), context.Canceled) || errors.Is(c.Request.Context().Err(), context.DeadlineExceeded) {
|
|
return
|
|
}
|
|
|
|
appErr := response.Normalize(err)
|
|
_ = writeSSE(c, "error", kolAssistStreamEvent{
|
|
Mode: req.Mode,
|
|
Status: "failed",
|
|
Content: strings.TrimSpace(streamed.String()),
|
|
Error: resolveKolAssistStreamErrorMessage(appErr),
|
|
Done: true,
|
|
})
|
|
flusher.Flush()
|
|
return
|
|
}
|
|
|
|
if err := writeSSE(c, "completed", kolAssistStreamEvent{
|
|
Mode: req.Mode,
|
|
Status: "completed",
|
|
Content: result.Content,
|
|
Schema: &result.Schema,
|
|
Model: model,
|
|
Done: true,
|
|
}); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
}
|
|
|
|
func (h *KolManageHandler) AssistGet(c *gin.Context) {
|
|
taskID := c.Param("id")
|
|
if taskID == "" {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "task id must not be empty"))
|
|
return
|
|
}
|
|
|
|
actor, ok := actorFromRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
task, err := h.assistSvc.Get(c.Request.Context(), actor, taskID)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
data, err := app.NewKolAssistTaskResponse(task)
|
|
if err != nil {
|
|
response.Error(c, err)
|
|
return
|
|
}
|
|
|
|
response.Success(c, data)
|
|
}
|
|
|
|
func actorFromRequest(c *gin.Context) (auth.Actor, bool) {
|
|
actor, ok := auth.ActorFromCtx(c.Request.Context())
|
|
if !ok {
|
|
response.Error(c, response.ErrUnauthorized(40100, "not_authenticated", "not authenticated"))
|
|
return auth.Actor{}, false
|
|
}
|
|
return actor, true
|
|
}
|
|
|
|
func parseInt64Param(c *gin.Context, key, label string) (int64, bool) {
|
|
id, err := strconv.ParseInt(c.Param(key), 10, 64)
|
|
if err != nil {
|
|
response.Error(c, response.ErrBadRequest(40001, "invalid_id", label+" must be a number"))
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
func resolveKolAssistStreamErrorMessage(appErr *response.AppError) string {
|
|
if appErr == nil {
|
|
return "AI 处理失败,请稍后重试"
|
|
}
|
|
if detail := strings.TrimSpace(appErr.Detail); detail != "" {
|
|
return detail
|
|
}
|
|
if message := strings.TrimSpace(appErr.Message); message != "" {
|
|
return message
|
|
}
|
|
return "AI 处理失败,请稍后重试"
|
|
}
|