feat(kol): KOL workspace services (profile/package/prompt/asset/renderer)

This commit is contained in:
2026-04-17 09:19:30 +08:00
parent 76f5c73773
commit 6029b0b169
6 changed files with 1066 additions and 0 deletions
@@ -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
}
@@ -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,
}
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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_")
}