feat(kol): KOL workspace services (profile/package/prompt/asset/renderer)
This commit is contained in:
@@ -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_")
|
||||
}
|
||||
Reference in New Issue
Block a user