From 6029b0b169ba1cdcb38bdca6d3cf6ea0d6368600 Mon Sep 17 00:00:00 2001 From: liangxu Date: Fri, 17 Apr 2026 09:19:30 +0800 Subject: [PATCH] feat(kol): KOL workspace services (profile/package/prompt/asset/renderer) --- .../tenant/app/kol_package_service.go | 319 +++++++++++++ .../tenant/app/kol_profile_service.go | 63 +++ .../internal/tenant/app/kol_prompt_asset.go | 36 ++ .../internal/tenant/app/kol_prompt_service.go | 423 ++++++++++++++++++ .../tenant/app/kol_variable_renderer.go | 127 ++++++ .../tenant/app/kol_variable_renderer_test.go | 98 ++++ 6 files changed, 1066 insertions(+) create mode 100644 server/internal/tenant/app/kol_package_service.go create mode 100644 server/internal/tenant/app/kol_profile_service.go create mode 100644 server/internal/tenant/app/kol_prompt_asset.go create mode 100644 server/internal/tenant/app/kol_prompt_service.go create mode 100644 server/internal/tenant/app/kol_variable_renderer.go create mode 100644 server/internal/tenant/app/kol_variable_renderer_test.go diff --git a/server/internal/tenant/app/kol_package_service.go b/server/internal/tenant/app/kol_package_service.go new file mode 100644 index 0000000..8131b29 --- /dev/null +++ b/server/internal/tenant/app/kol_package_service.go @@ -0,0 +1,319 @@ +package app + +import ( + "context" + "encoding/json" + "strings" + "time" + + "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" +) + +type CreateKolPackageInput struct { + Name string `json:"name" binding:"required"` + Description *string `json:"description"` + CoverURL *string `json:"cover_url"` + Industry *string `json:"industry"` + Tags []string `json:"tags"` + PriceNote *string `json:"price_note"` +} + +type UpdateKolPackageInput = CreateKolPackageInput + +type KolPackageResponse struct { + ID int64 `json:"id"` + KolProfileID int64 `json:"kol_profile_id"` + KolDisplayName string `json:"kol_display_name"` + KolAvatarURL *string `json:"kol_avatar_url"` + Name string `json:"name"` + Description *string `json:"description"` + CoverURL *string `json:"cover_url"` + Industry *string `json:"industry"` + Tags []string `json:"tags"` + PriceNote *string `json:"price_note"` + Status string `json:"status"` + PromptCount int `json:"prompt_count"` + SubscriberCount int `json:"subscriber_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type KolPackageService struct { + profileSvc *KolProfileService + repo repository.KolPackageRepository + promptRepo repository.KolPromptRepository +} + +func NewKolPackageService( + profileSvc *KolProfileService, + repo repository.KolPackageRepository, + promptRepo repository.KolPromptRepository, +) *KolPackageService { + return &KolPackageService{ + profileSvc: profileSvc, + repo: repo, + promptRepo: promptRepo, + } +} + +func (s *KolPackageService) List(ctx context.Context, actor auth.Actor) ([]KolPackageResponse, error) { + profile, err := s.profileSvc.RequireActiveKol(ctx, actor) + if err != nil { + return nil, err + } + + packages, err := s.repo.ListByProfile(ctx, actor.TenantID, profile.ID) + if err != nil { + return nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error()) + } + + result := make([]KolPackageResponse, 0, len(packages)) + for _, pkg := range packages { + item, err := s.buildPackageResponse(ctx, profile, &pkg) + if err != nil { + return nil, err + } + result = append(result, *item) + } + + return result, nil +} + +func (s *KolPackageService) Get(ctx context.Context, actor auth.Actor, id int64) (*KolPackageResponse, error) { + profile, pkg, err := s.loadOwnedPackage(ctx, actor, id) + if err != nil { + return nil, err + } + return s.buildPackageResponse(ctx, profile, pkg) +} + +func (s *KolPackageService) Create(ctx context.Context, actor auth.Actor, input CreateKolPackageInput) (*KolPackageResponse, error) { + profile, err := s.profileSvc.RequireActiveKol(ctx, actor) + if err != nil { + return nil, err + } + + name := strings.TrimSpace(input.Name) + if name == "" { + return nil, response.ErrBadRequest(40061, "kol_package_name_required", "package name is required") + } + + tagsJSON, err := json.Marshal(normalizeKolStringList(input.Tags)) + if err != nil { + return nil, response.ErrBadRequest(40062, "kol_package_tags_invalid", "tags must be valid json") + } + + pkg, err := s.repo.Create(ctx, repository.CreateKolPackageInput{ + TenantID: actor.TenantID, + KolProfileID: profile.ID, + Name: name, + Description: normalizeKolOptionalString(input.Description), + CoverURL: normalizeKolOptionalString(input.CoverURL), + Industry: normalizeKolOptionalString(input.Industry), + Tags: tagsJSON, + PriceNote: normalizeKolOptionalString(input.PriceNote), + }) + if err != nil { + return nil, response.ErrInternal(50063, "kol_package_create_failed", err.Error()) + } + + return s.buildPackageResponse(ctx, profile, pkg) +} + +func (s *KolPackageService) Update(ctx context.Context, actor auth.Actor, id int64, input UpdateKolPackageInput) (*KolPackageResponse, error) { + profile, _, err := s.loadOwnedPackage(ctx, actor, id) + if err != nil { + return nil, err + } + + name := strings.TrimSpace(input.Name) + if name == "" { + return nil, response.ErrBadRequest(40061, "kol_package_name_required", "package name is required") + } + + tagsJSON, err := json.Marshal(normalizeKolStringList(input.Tags)) + if err != nil { + return nil, response.ErrBadRequest(40062, "kol_package_tags_invalid", "tags must be valid json") + } + + if err := s.repo.Update(ctx, actor.TenantID, repository.UpdateKolPackageInput{ + ID: id, + Name: name, + Description: normalizeKolOptionalString(input.Description), + CoverURL: normalizeKolOptionalString(input.CoverURL), + Industry: normalizeKolOptionalString(input.Industry), + Tags: tagsJSON, + PriceNote: normalizeKolOptionalString(input.PriceNote), + }); err != nil { + return nil, response.ErrInternal(50064, "kol_package_update_failed", err.Error()) + } + + updated, err := s.repo.GetByID(ctx, actor.TenantID, id) + if err != nil { + return nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error()) + } + if updated == nil { + return nil, errKolPackageNotFound + } + + return s.buildPackageResponse(ctx, profile, updated) +} + +func (s *KolPackageService) Publish(ctx context.Context, actor auth.Actor, id int64) (*KolPackageResponse, error) { + profile, _, err := s.loadOwnedPackage(ctx, actor, id) + if err != nil { + return nil, err + } + + if err := s.repo.UpdateStatus(ctx, actor.TenantID, id, "published"); err != nil { + return nil, response.ErrInternal(50065, "kol_package_publish_failed", err.Error()) + } + + updated, err := s.repo.GetByID(ctx, actor.TenantID, id) + if err != nil { + return nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error()) + } + if updated == nil { + return nil, errKolPackageNotFound + } + + return s.buildPackageResponse(ctx, profile, updated) +} + +func (s *KolPackageService) Archive(ctx context.Context, actor auth.Actor, id int64) (*KolPackageResponse, error) { + profile, _, err := s.loadOwnedPackage(ctx, actor, id) + if err != nil { + return nil, err + } + + if err := s.repo.UpdateStatus(ctx, actor.TenantID, id, "archived"); err != nil { + return nil, response.ErrInternal(50066, "kol_package_archive_failed", err.Error()) + } + + updated, err := s.repo.GetByID(ctx, actor.TenantID, id) + if err != nil { + return nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error()) + } + if updated == nil { + return nil, errKolPackageNotFound + } + + return s.buildPackageResponse(ctx, profile, updated) +} + +func (s *KolPackageService) Delete(ctx context.Context, actor auth.Actor, id int64) error { + if _, _, err := s.loadOwnedPackage(ctx, actor, id); err != nil { + return err + } + + if err := s.repo.SoftDelete(ctx, actor.TenantID, id); err != nil { + return response.ErrInternal(50067, "kol_package_delete_failed", err.Error()) + } + + return nil +} + +func (s *KolPackageService) loadOwnedPackage(ctx context.Context, actor auth.Actor, id int64) (*repository.KolProfile, *repository.KolPackage, error) { + profile, err := s.profileSvc.RequireActiveKol(ctx, actor) + if err != nil { + return nil, nil, err + } + + pkg, err := s.repo.GetByID(ctx, actor.TenantID, id) + 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 *KolPackageService) buildPackageResponse( + ctx context.Context, + profile *repository.KolProfile, + pkg *repository.KolPackage, +) (*KolPackageResponse, error) { + tags, err := decodeKolStringList(pkg.Tags) + if err != nil { + return nil, response.ErrInternal(50068, "kol_package_decode_failed", err.Error()) + } + + promptCount := 0 + if s.promptRepo != nil { + prompts, err := s.promptRepo.ListByPackage(ctx, pkg.TenantID, pkg.ID) + if err != nil { + return nil, response.ErrInternal(50069, "kol_prompt_query_failed", err.Error()) + } + promptCount = len(prompts) + } + + return &KolPackageResponse{ + ID: pkg.ID, + KolProfileID: pkg.KolProfileID, + KolDisplayName: profile.DisplayName, + KolAvatarURL: profile.AvatarURL, + Name: pkg.Name, + Description: pkg.Description, + CoverURL: pkg.CoverURL, + Industry: pkg.Industry, + Tags: tags, + PriceNote: pkg.PriceNote, + Status: pkg.Status, + PromptCount: promptCount, + SubscriberCount: 0, + CreatedAt: pkg.CreatedAt, + UpdatedAt: pkg.UpdatedAt, + }, nil +} + +func normalizeKolOptionalString(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func normalizeKolStringList(values []string) []string { + if len(values) == 0 { + return []string{} + } + + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, exists := seen[trimmed]; exists { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + + return result +} + +func decodeKolStringList(raw []byte) ([]string, error) { + if len(raw) == 0 { + return []string{}, nil + } + + var values []string + if err := json.Unmarshal(raw, &values); err != nil { + return nil, err + } + return normalizeKolStringList(values), nil +} diff --git a/server/internal/tenant/app/kol_profile_service.go b/server/internal/tenant/app/kol_profile_service.go new file mode 100644 index 0000000..0bbe5ee --- /dev/null +++ b/server/internal/tenant/app/kol_profile_service.go @@ -0,0 +1,63 @@ +package app + +import ( + "context" + "time" + + "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 ErrNotActiveKol = response.ErrForbidden(40341, "kol_not_active", "user is not an active KOL") + +type KolProfileService struct { + repo repository.KolProfileRepository +} + +type KolProfileResponse struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + UserID int64 `json:"user_id"` + DisplayName string `json:"display_name"` + Bio *string `json:"bio"` + AvatarURL *string `json:"avatar_url"` + MarketEnabled bool `json:"market_enabled"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func NewKolProfileService(repo repository.KolProfileRepository) *KolProfileService { + return &KolProfileService{repo: repo} +} + +func (s *KolProfileService) RequireActiveKol(ctx context.Context, actor auth.Actor) (*repository.KolProfile, error) { + profile, err := s.repo.GetByUser(ctx, actor.TenantID, actor.UserID) + if err != nil { + return nil, response.ErrInternal(50061, "kol_profile_query_failed", err.Error()) + } + if profile == nil || profile.Status != "active" { + return nil, ErrNotActiveKol + } + return profile, nil +} + +func NewKolProfileResponse(profile *repository.KolProfile) *KolProfileResponse { + if profile == nil { + return nil + } + + return &KolProfileResponse{ + ID: profile.ID, + TenantID: profile.TenantID, + UserID: profile.UserID, + DisplayName: profile.DisplayName, + Bio: profile.Bio, + AvatarURL: profile.AvatarURL, + MarketEnabled: profile.MarketEnabled, + Status: profile.Status, + CreatedAt: profile.CreatedAt, + UpdatedAt: profile.UpdatedAt, + } +} diff --git a/server/internal/tenant/app/kol_prompt_asset.go b/server/internal/tenant/app/kol_prompt_asset.go new file mode 100644 index 0000000..def1215 --- /dev/null +++ b/server/internal/tenant/app/kol_prompt_asset.go @@ -0,0 +1,36 @@ +package app + +import ( + "context" + "fmt" + + "github.com/geo-platform/tenant-api/internal/shared/objectstorage" +) + +type KolPromptAsset struct { + storage objectstorage.Client +} + +func NewKolPromptAsset(storage objectstorage.Client) *KolPromptAsset { + return &KolPromptAsset{storage: storage} +} + +func (a *KolPromptAsset) assetKey(tenantID, promptID int64, revisionNo int) string { + return fmt.Sprintf("kol-prompts/%d/%d/%d.txt", tenantID, promptID, revisionNo) +} + +func (a *KolPromptAsset) Put(ctx context.Context, tenantID, promptID int64, revisionNo int, content string) (string, error) { + key := a.assetKey(tenantID, promptID, revisionNo) + if err := a.storage.PutBytes(ctx, key, []byte(content), "text/plain; charset=utf-8"); err != nil { + return "", err + } + return key, nil +} + +func (a *KolPromptAsset) Get(ctx context.Context, assetKey string) (string, error) { + content, err := a.storage.GetBytes(ctx, assetKey) + if err != nil { + return "", err + } + return string(content), nil +} diff --git a/server/internal/tenant/app/kol_prompt_service.go b/server/internal/tenant/app/kol_prompt_service.go new file mode 100644 index 0000000..30810e9 --- /dev/null +++ b/server/internal/tenant/app/kol_prompt_service.go @@ -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 +} diff --git a/server/internal/tenant/app/kol_variable_renderer.go b/server/internal/tenant/app/kol_variable_renderer.go new file mode 100644 index 0000000..091abc1 --- /dev/null +++ b/server/internal/tenant/app/kol_variable_renderer.go @@ -0,0 +1,127 @@ +package app + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "regexp" + "strings" +) + +type KolVariableType string + +const ( + KolVariableTypeInput KolVariableType = "input" + KolVariableTypeTextarea KolVariableType = "textarea" + KolVariableTypeSelect KolVariableType = "select" + KolVariableTypeNumber KolVariableType = "number" + KolVariableTypeCheckbox KolVariableType = "checkbox" +) + +type KolVariableDefinition struct { + ID string `json:"id"` + Key string `json:"key"` + Label string `json:"label"` + Type KolVariableType `json:"type"` + Required bool `json:"required"` + Placeholder *string `json:"placeholder,omitempty"` + Options []string `json:"options,omitempty"` +} + +type KolSchemaJSON struct { + Variables []KolVariableDefinition `json:"variables"` +} + +var kolPlaceholderRE = regexp.MustCompile(`\{\{\s*([^}\s]+)\s*\}\}`) + +func RenderPrompt(content string, schema KolSchemaJSON, values map[string]any) (string, error) { + schema = normalizeKolSchema(schema) + + byID := make(map[string]KolVariableDefinition, len(schema.Variables)) + for _, variable := range schema.Variables { + byID[variable.ID] = variable + } + + missingSet := make(map[string]struct{}) + missing := make([]string, 0) + addMissing := func(id string) { + if id == "" { + return + } + if _, exists := missingSet[id]; exists { + return + } + missingSet[id] = struct{}{} + missing = append(missing, id) + } + + rendered := kolPlaceholderRE.ReplaceAllStringFunc(content, func(match string) string { + submatches := kolPlaceholderRE.FindStringSubmatch(match) + if len(submatches) < 2 { + return match + } + + id := submatches[1] + variable, exists := byID[id] + if !exists { + addMissing(id) + return match + } + + value, hasValue := values[id] + if hasValue { + return fmt.Sprint(value) + } + if variable.Required { + addMissing(id) + } + return "" + }) + + if len(missing) > 0 { + return "", fmt.Errorf("missing variables: %s", strings.Join(missing, ", ")) + } + + return rendered, nil +} + +func HashPrompt(rendered string) string { + sum := sha256.Sum256([]byte(rendered)) + return hex.EncodeToString(sum[:]) +} + +func ValidateSchema(schema KolSchemaJSON) error { + schema = normalizeKolSchema(schema) + + seen := make(map[string]struct{}, len(schema.Variables)) + for _, variable := range schema.Variables { + id := strings.TrimSpace(variable.ID) + if id == "" || !strings.HasPrefix(id, "var_") { + return fmt.Errorf("variable id must be non-empty and start with var_") + } + if _, exists := seen[id]; exists { + return fmt.Errorf("duplicate variable id: %s", id) + } + seen[id] = struct{}{} + + switch KolVariableType(strings.TrimSpace(string(variable.Type))) { + case KolVariableTypeInput, KolVariableTypeTextarea, KolVariableTypeSelect, KolVariableTypeNumber, KolVariableTypeCheckbox: + default: + return fmt.Errorf("unsupported variable type: %s", variable.Type) + } + } + + return nil +} + +func JSONEncodeSchema(schema KolSchemaJSON) ([]byte, error) { + return json.Marshal(normalizeKolSchema(schema)) +} + +func normalizeKolSchema(schema KolSchemaJSON) KolSchemaJSON { + if schema.Variables == nil { + schema.Variables = []KolVariableDefinition{} + } + return schema +} diff --git a/server/internal/tenant/app/kol_variable_renderer_test.go b/server/internal/tenant/app/kol_variable_renderer_test.go new file mode 100644 index 0000000..a5160c7 --- /dev/null +++ b/server/internal/tenant/app/kol_variable_renderer_test.go @@ -0,0 +1,98 @@ +package app + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestKolVariableRenderPrompt(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + content string + schema KolSchemaJSON + values map[string]any + want string + wantErr string + }{ + { + name: "success", + content: "Hello {{var_a}} from {{var_b}}", + schema: KolSchemaJSON{ + Variables: []KolVariableDefinition{ + {ID: "var_a", Type: KolVariableTypeInput, Required: true}, + {ID: "var_b", Type: KolVariableTypeSelect}, + }, + }, + values: map[string]any{ + "var_a": "宜家", + "var_b": "家具", + }, + want: "Hello 宜家 from 家具", + }, + { + name: "missing required", + content: "Hello {{var_required}}", + schema: KolSchemaJSON{ + Variables: []KolVariableDefinition{ + {ID: "var_required", Type: KolVariableTypeInput, Required: true}, + }, + }, + values: map[string]any{}, + wantErr: "missing variables", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + rendered, err := RenderPrompt(tc.content, tc.schema, tc.values) + if tc.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErr) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, rendered) + }) + } +} + +func TestKolVariableHashPromptStable(t *testing.T) { + t.Parallel() + + require.Equal(t, HashPrompt("abc"), HashPrompt("abc")) + require.NotEqual(t, HashPrompt("abc"), HashPrompt("abd")) +} + +func TestKolVariableValidateSchemaRejectsDuplicateID(t *testing.T) { + t.Parallel() + + err := ValidateSchema(KolSchemaJSON{ + Variables: []KolVariableDefinition{ + {ID: "var_a", Type: KolVariableTypeInput}, + {ID: "var_a", Type: KolVariableTypeSelect}, + }, + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "duplicate variable id") +} + +func TestKolVariableValidateSchemaRejectsInvalidID(t *testing.T) { + t.Parallel() + + err := ValidateSchema(KolSchemaJSON{ + Variables: []KolVariableDefinition{ + {ID: "bad_id", Type: KolVariableTypeInput}, + }, + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "start with var_") +}