feat(kol): KOL workspace services (profile/package/prompt/asset/renderer)
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"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/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
errKolPackageNotFound = response.ErrNotFound(40461, "kol_package_not_found", "package not found")
|
||||
ErrPackageNotOwned = response.ErrForbidden(40361, "kol_package_not_owned", "package is not owned by current KOL")
|
||||
ErrPromptNotFound = response.ErrNotFound(40462, "kol_prompt_not_found", "prompt not found")
|
||||
ErrPromptNotOwned = response.ErrForbidden(40362, "kol_prompt_not_owned", "prompt is not owned by current KOL")
|
||||
errKolPromptDraftEmpty = response.ErrConflict(40961, "kol_prompt_draft_missing", "no draft revision to publish")
|
||||
)
|
||||
|
||||
type CreatePromptInput struct {
|
||||
PackageID int64 `json:"-"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
PlatformHint *string `json:"platform_hint"`
|
||||
}
|
||||
|
||||
type UpdateKolPromptInput struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
PlatformHint *string `json:"platform_hint"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type SaveDraftInput struct {
|
||||
PromptID int64 `json:"-"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Schema KolSchemaJSON `json:"schema" binding:"required"`
|
||||
CardConfig map[string]interface{} `json:"card_config"`
|
||||
}
|
||||
|
||||
type KolPromptDetailResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
PackageID int64 `json:"package_id"`
|
||||
Name string `json:"name"`
|
||||
PlatformHint *string `json:"platform_hint"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
PublishedRevisionNo *int `json:"published_revision_no"`
|
||||
DraftRevisionNo *int `json:"draft_revision_no"`
|
||||
LatestSchemaJSON *KolSchemaJSON `json:"latest_schema_json"`
|
||||
LatestCardConfigJSON map[string]interface{} `json:"latest_card_config_json"`
|
||||
LatestPromptContent *string `json:"latest_prompt_content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type KolPromptRevisionResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
PromptID int64 `json:"prompt_id"`
|
||||
RevisionNo int `json:"revision_no"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type KolPromptService struct {
|
||||
pool *pgxpool.Pool
|
||||
profileSvc *KolProfileService
|
||||
pkgRepo repository.KolPackageRepository
|
||||
promptRepo repository.KolPromptRepository
|
||||
asset *KolPromptAsset
|
||||
}
|
||||
|
||||
func NewKolPromptService(
|
||||
pool *pgxpool.Pool,
|
||||
profileSvc *KolProfileService,
|
||||
pkgRepo repository.KolPackageRepository,
|
||||
promptRepo repository.KolPromptRepository,
|
||||
asset *KolPromptAsset,
|
||||
) *KolPromptService {
|
||||
return &KolPromptService{
|
||||
pool: pool,
|
||||
profileSvc: profileSvc,
|
||||
pkgRepo: pkgRepo,
|
||||
promptRepo: promptRepo,
|
||||
asset: asset,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *KolPromptService) CreatePrompt(ctx context.Context, actor auth.Actor, input CreatePromptInput) (*KolPromptDetailResponse, error) {
|
||||
if strings.TrimSpace(input.Name) == "" {
|
||||
return nil, response.ErrBadRequest(40063, "kol_prompt_name_required", "prompt name is required")
|
||||
}
|
||||
|
||||
if _, _, err := s.loadOwnedPackage(ctx, actor, input.PackageID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prompt, err := s.promptRepo.Create(ctx, repository.CreateKolPromptInput{
|
||||
TenantID: actor.TenantID,
|
||||
PackageID: input.PackageID,
|
||||
Name: strings.TrimSpace(input.Name),
|
||||
PlatformHint: normalizeKolOptionalString(input.PlatformHint),
|
||||
SortOrder: 0,
|
||||
CreatedBy: actor.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50070, "kol_prompt_create_failed", err.Error())
|
||||
}
|
||||
|
||||
return s.buildPromptDetail(ctx, prompt, false)
|
||||
}
|
||||
|
||||
func (s *KolPromptService) ListByPackage(ctx context.Context, actor auth.Actor, packageID int64) ([]KolPromptDetailResponse, error) {
|
||||
if _, _, err := s.loadOwnedPackage(ctx, actor, packageID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prompts, err := s.promptRepo.ListByPackage(ctx, actor.TenantID, packageID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error())
|
||||
}
|
||||
|
||||
result := make([]KolPromptDetailResponse, 0, len(prompts))
|
||||
for _, prompt := range prompts {
|
||||
item, err := s.buildPromptDetail(ctx, &prompt, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, *item)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *KolPromptService) GetForOwner(ctx context.Context, actor auth.Actor, promptID int64) (*KolPromptDetailResponse, error) {
|
||||
_, prompt, _, err := s.loadOwnedPrompt(ctx, actor, promptID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.buildPromptDetail(ctx, prompt, true)
|
||||
}
|
||||
|
||||
func (s *KolPromptService) UpdateMetadata(ctx context.Context, actor auth.Actor, promptID int64, input UpdateKolPromptInput) (*KolPromptDetailResponse, error) {
|
||||
_, prompt, _, err := s.loadOwnedPrompt(ctx, actor, promptID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
return nil, response.ErrBadRequest(40063, "kol_prompt_name_required", "prompt name is required")
|
||||
}
|
||||
|
||||
sortOrder := prompt.SortOrder
|
||||
if input.SortOrder != nil {
|
||||
sortOrder = *input.SortOrder
|
||||
}
|
||||
|
||||
if err := s.promptRepo.UpdateMetadata(ctx, repository.UpdateKolPromptMetadataInput{
|
||||
ID: promptID,
|
||||
TenantID: actor.TenantID,
|
||||
Name: name,
|
||||
PlatformHint: normalizeKolOptionalString(input.PlatformHint),
|
||||
SortOrder: sortOrder,
|
||||
UpdatedBy: actor.UserID,
|
||||
}); err != nil {
|
||||
return nil, response.ErrInternal(50072, "kol_prompt_update_failed", err.Error())
|
||||
}
|
||||
|
||||
updated, err := s.promptRepo.GetByID(ctx, actor.TenantID, promptID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error())
|
||||
}
|
||||
if updated == nil {
|
||||
return nil, ErrPromptNotFound
|
||||
}
|
||||
|
||||
return s.buildPromptDetail(ctx, updated, false)
|
||||
}
|
||||
|
||||
func (s *KolPromptService) SaveDraft(ctx context.Context, actor auth.Actor, input SaveDraftInput) (*KolPromptRevisionResponse, error) {
|
||||
if strings.TrimSpace(input.Content) == "" {
|
||||
return nil, response.ErrBadRequest(40064, "kol_prompt_content_required", "prompt content is required")
|
||||
}
|
||||
if err := ValidateSchema(input.Schema); err != nil {
|
||||
return nil, response.ErrBadRequest(40065, "kol_prompt_schema_invalid", err.Error())
|
||||
}
|
||||
|
||||
_, prompt, _, err := s.loadOwnedPrompt(ctx, actor, input.PromptID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nextRevisionNo, err := s.promptRepo.NextRevisionNo(ctx, actor.TenantID, input.PromptID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50073, "kol_prompt_revision_query_failed", err.Error())
|
||||
}
|
||||
|
||||
assetKey, err := s.asset.Put(ctx, actor.TenantID, input.PromptID, nextRevisionNo, input.Content)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50074, "kol_prompt_asset_store_failed", err.Error())
|
||||
}
|
||||
|
||||
schemaJSON, err := JSONEncodeSchema(input.Schema)
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40065, "kol_prompt_schema_invalid", err.Error())
|
||||
}
|
||||
|
||||
cardConfigJSON, err := json.Marshal(normalizeKolCardConfig(input.CardConfig))
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40066, "kol_prompt_card_config_invalid", "card_config must be valid json")
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50075, "kol_prompt_draft_tx_failed", "failed to begin prompt draft transaction")
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
promptTx := repository.NewKolPromptRepository(tx)
|
||||
revision, err := promptTx.CreateRevision(ctx, repository.CreateKolPromptRevisionInput{
|
||||
TenantID: actor.TenantID,
|
||||
PromptID: prompt.ID,
|
||||
RevisionNo: nextRevisionNo,
|
||||
PromptAssetKey: assetKey,
|
||||
SchemaJSON: schemaJSON,
|
||||
CardConfigJSON: cardConfigJSON,
|
||||
CreatedBy: actor.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50076, "kol_prompt_draft_create_failed", err.Error())
|
||||
}
|
||||
|
||||
if err := promptTx.SetDraftRevision(ctx, actor.TenantID, prompt.ID, nextRevisionNo, actor.UserID); err != nil {
|
||||
return nil, response.ErrInternal(50077, "kol_prompt_draft_update_failed", err.Error())
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50078, "kol_prompt_draft_commit_failed", err.Error())
|
||||
}
|
||||
|
||||
return &KolPromptRevisionResponse{
|
||||
ID: revision.ID,
|
||||
PromptID: revision.PromptID,
|
||||
RevisionNo: revision.RevisionNo,
|
||||
CreatedAt: revision.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *KolPromptService) Publish(ctx context.Context, actor auth.Actor, promptID int64) error {
|
||||
_, prompt, _, err := s.loadOwnedPrompt(ctx, actor, promptID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prompt.DraftRevisionNo == nil {
|
||||
return errKolPromptDraftEmpty
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return response.ErrInternal(50079, "kol_prompt_publish_tx_failed", "failed to begin prompt publish transaction")
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if err := repository.NewKolPromptRepository(tx).SetPublishedRevision(ctx, actor.TenantID, promptID, *prompt.DraftRevisionNo, actor.UserID); err != nil {
|
||||
return response.ErrInternal(50080, "kol_prompt_publish_failed", err.Error())
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return response.ErrInternal(50081, "kol_prompt_publish_commit_failed", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KolPromptService) Delete(ctx context.Context, actor auth.Actor, promptID int64) error {
|
||||
if _, _, _, err := s.loadOwnedPrompt(ctx, actor, promptID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.promptRepo.SoftDelete(ctx, actor.TenantID, promptID); err != nil {
|
||||
return response.ErrInternal(50082, "kol_prompt_delete_failed", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KolPromptService) loadOwnedPackage(ctx context.Context, actor auth.Actor, packageID int64) (*repository.KolProfile, *repository.KolPackage, error) {
|
||||
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pkg, err := s.pkgRepo.GetByID(ctx, actor.TenantID, packageID)
|
||||
if err != nil {
|
||||
return nil, nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error())
|
||||
}
|
||||
if pkg == nil {
|
||||
return nil, nil, errKolPackageNotFound
|
||||
}
|
||||
if pkg.KolProfileID != profile.ID {
|
||||
return nil, nil, ErrPackageNotOwned
|
||||
}
|
||||
|
||||
return profile, pkg, nil
|
||||
}
|
||||
|
||||
func (s *KolPromptService) loadOwnedPrompt(ctx context.Context, actor auth.Actor, promptID int64) (*repository.KolProfile, *repository.KolPrompt, *repository.KolPackage, error) {
|
||||
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
prompt, err := s.promptRepo.GetByID(ctx, actor.TenantID, promptID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error())
|
||||
}
|
||||
if prompt == nil {
|
||||
return nil, nil, nil, ErrPromptNotFound
|
||||
}
|
||||
|
||||
pkg, err := s.pkgRepo.GetByID(ctx, actor.TenantID, prompt.PackageID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error())
|
||||
}
|
||||
if pkg == nil {
|
||||
return nil, nil, nil, errKolPackageNotFound
|
||||
}
|
||||
if pkg.KolProfileID != profile.ID {
|
||||
return nil, nil, nil, ErrPromptNotOwned
|
||||
}
|
||||
|
||||
return profile, prompt, pkg, nil
|
||||
}
|
||||
|
||||
func (s *KolPromptService) buildPromptDetail(ctx context.Context, prompt *repository.KolPrompt, includeContent bool) (*KolPromptDetailResponse, error) {
|
||||
detail := &KolPromptDetailResponse{
|
||||
ID: prompt.ID,
|
||||
PackageID: prompt.PackageID,
|
||||
Name: prompt.Name,
|
||||
PlatformHint: prompt.PlatformHint,
|
||||
Status: prompt.Status,
|
||||
SortOrder: prompt.SortOrder,
|
||||
PublishedRevisionNo: prompt.PublishedRevisionNo,
|
||||
DraftRevisionNo: prompt.DraftRevisionNo,
|
||||
CreatedAt: prompt.CreatedAt,
|
||||
UpdatedAt: prompt.UpdatedAt,
|
||||
}
|
||||
|
||||
revisionNo := prompt.DraftRevisionNo
|
||||
if revisionNo == nil {
|
||||
revisionNo = prompt.PublishedRevisionNo
|
||||
}
|
||||
if revisionNo == nil {
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
revision, err := s.promptRepo.GetRevision(ctx, prompt.TenantID, prompt.ID, *revisionNo)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50083, "kol_prompt_revision_query_failed", err.Error())
|
||||
}
|
||||
if revision == nil {
|
||||
return nil, response.ErrInternal(50084, "kol_prompt_revision_not_found", "prompt revision not found")
|
||||
}
|
||||
|
||||
schema, err := decodeKolSchema(revision.SchemaJSON)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50085, "kol_prompt_schema_decode_failed", err.Error())
|
||||
}
|
||||
cardConfig, err := decodeKolCardConfig(revision.CardConfigJSON)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50086, "kol_prompt_card_config_decode_failed", err.Error())
|
||||
}
|
||||
|
||||
detail.LatestSchemaJSON = schema
|
||||
detail.LatestCardConfigJSON = cardConfig
|
||||
|
||||
if includeContent {
|
||||
content, err := s.asset.Get(ctx, revision.PromptAssetKey)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50087, "kol_prompt_asset_load_failed", err.Error())
|
||||
}
|
||||
detail.LatestPromptContent = &content
|
||||
}
|
||||
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func normalizeKolCardConfig(cardConfig map[string]interface{}) map[string]interface{} {
|
||||
if cardConfig == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return cardConfig
|
||||
}
|
||||
|
||||
func decodeKolSchema(raw []byte) (*KolSchemaJSON, error) {
|
||||
schema := normalizeKolSchema(KolSchemaJSON{})
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
return &schema, nil
|
||||
}
|
||||
if err := json.Unmarshal(trimmed, &schema); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
schema = normalizeKolSchema(schema)
|
||||
return &schema, nil
|
||||
}
|
||||
|
||||
func decodeKolCardConfig(raw []byte) (map[string]interface{}, error) {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
|
||||
var cardConfig map[string]interface{}
|
||||
if err := json.Unmarshal(trimmed, &cardConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeKolCardConfig(cardConfig), nil
|
||||
}
|
||||
Reference in New Issue
Block a user