Files
geo/server/internal/tenant/app/kol_marketplace_service.go
T
root 8761e47f78 feat(kol): cache marketplace and generation reads with singleflight
Add cache-aside + singleflight to KOL marketplace list/detail, my-subscription
prompts, and generation schema lookups. Invalidate the shared public-facing
caches when subscriptions are added/revoked (ops) or KOL profile is updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:33:17 +08:00

312 lines
11 KiB
Go

package app
import (
"context"
"time"
"github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
"golang.org/x/sync/singleflight"
)
var (
errKolMarketplacePackageNotFound = response.ErrNotFound(40471, "kol_marketplace_package_not_found", "package not found")
errKolSubscriptionNotFound = response.ErrNotFound(40472, "kol_subscription_not_found", "subscription not found")
errKolSubscriptionExists = response.ErrConflict(40971, "kol_subscription_exists", "subscription already exists")
errKolSubscriptionNotPending = response.ErrConflict(40972, "kol_subscription_not_pending", "subscription is not pending")
errKolSelfSubscribeViaMarket = response.ErrBadRequest(40074, "kol_self_subscribe_via_marketplace", "自己的订阅包请在 KOL 工作台订阅")
)
type MarketFilter struct {
Industry *string
Keyword *string
Offset int
Limit int
}
type KolMarketplacePackageResponse 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"`
OwnedByCurrentTenant bool `json:"owned_by_current_tenant"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type KolMarketplacePromptResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
PlatformHint *string `json:"platform_hint"`
Status string `json:"status"`
PublishedRevisionNo *int `json:"published_revision_no"`
CreatedAt string `json:"created_at"`
}
type KolMarketplacePackageDetailResponse struct {
KolMarketplacePackageResponse
KolBio *string `json:"kol_bio"`
Prompts []KolMarketplacePromptResponse `json:"prompts"`
Subscription *KolSubscriptionResponse `json:"subscription"`
}
type KolSubscriptionResponse struct {
ID int64 `json:"id"`
PackageID int64 `json:"package_id"`
Status string `json:"status"`
StartAt *string `json:"start_at"`
EndAt *string `json:"end_at"`
CreatedAt string `json:"created_at"`
}
type KolSubscriptionPromptCardResponse struct {
ID int64 `json:"id"`
PackageID int64 `json:"package_id"`
PackageName string `json:"package_name"`
PromptID int64 `json:"prompt_id"`
PromptName string `json:"prompt_name"`
PlatformHint *string `json:"platform_hint"`
KolDisplayName string `json:"kol_display_name"`
GrantedAt string `json:"granted_at"`
}
type KolMarketplaceService struct {
marketplaceRepo repository.KolMarketplaceRepository
subRepo repository.KolSubscriptionRepository
cache sharedcache.Cache
cacheGroup singleflight.Group
}
func NewKolMarketplaceService(
marketplaceRepo repository.KolMarketplaceRepository,
subRepo repository.KolSubscriptionRepository,
) *KolMarketplaceService {
return &KolMarketplaceService{
marketplaceRepo: marketplaceRepo,
subRepo: subRepo,
}
}
func (s *KolMarketplaceService) WithCache(c sharedcache.Cache) *KolMarketplaceService {
s.cache = c
return s
}
func (s *KolMarketplaceService) ListPackages(ctx context.Context, actor auth.Actor, filter MarketFilter) ([]KolMarketplacePackageResponse, error) {
filter = normalizeMarketFilter(filter)
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, kolMarketplacePackageListCacheKey(actor.TenantID, filter), defaultCacheTTL(), func(loadCtx context.Context) ([]KolMarketplacePackageResponse, error) {
packages, err := s.marketplaceRepo.ListPublished(loadCtx, repository.ListPublishedFilter{
Industry: filter.Industry,
Keyword: filter.Keyword,
Offset: filter.Offset,
Limit: filter.Limit,
})
if err != nil {
return nil, response.ErrInternal(50090, "kol_marketplace_query_failed", err.Error())
}
result := make([]KolMarketplacePackageResponse, 0, len(packages))
for _, pkg := range packages {
item, err := newKolMarketplacePackageResponse(pkg, actor.TenantID)
if err != nil {
return nil, response.ErrInternal(50091, "kol_marketplace_decode_failed", err.Error())
}
result = append(result, *item)
}
return result, nil
})
}
func (s *KolMarketplaceService) GetPackage(ctx context.Context, actor auth.Actor, id int64) (*KolMarketplacePackageDetailResponse, error) {
record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, kolMarketplacePackageDetailCacheKey(actor.TenantID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*KolMarketplacePackageDetailResponse, bool, error) {
pkg, err := s.marketplaceRepo.GetPublished(loadCtx, id)
if err != nil {
return nil, false, response.ErrInternal(50090, "kol_marketplace_query_failed", err.Error())
}
if pkg == nil {
return nil, false, nil
}
prompts, err := s.marketplaceRepo.ListPublicPrompts(loadCtx, id)
if err != nil {
return nil, false, response.ErrInternal(50092, "kol_marketplace_prompt_query_failed", err.Error())
}
sub, err := s.subRepo.GetActiveForTenant(loadCtx, actor.TenantID, id)
if err != nil {
return nil, false, response.ErrInternal(50093, "kol_subscription_query_failed", err.Error())
}
pkgResp, err := newKolMarketplacePackageResponse(*pkg, actor.TenantID)
if err != nil {
return nil, false, response.ErrInternal(50091, "kol_marketplace_decode_failed", err.Error())
}
resp := &KolMarketplacePackageDetailResponse{
KolMarketplacePackageResponse: *pkgResp,
KolBio: pkg.KolBio,
Prompts: make([]KolMarketplacePromptResponse, 0, len(prompts)),
Subscription: newKolSubscriptionResponse(sub),
}
for _, prompt := range prompts {
resp.Prompts = append(resp.Prompts, KolMarketplacePromptResponse{
ID: prompt.ID,
Name: prompt.Name,
PlatformHint: prompt.PlatformHint,
Status: prompt.Status,
PublishedRevisionNo: prompt.PublishedRevisionNo,
CreatedAt: prompt.CreatedAt.Format(time.RFC3339),
})
}
return resp, true, nil
})
if err != nil {
return nil, err
}
if !found || record == nil {
return nil, errKolMarketplacePackageNotFound
}
return record, nil
}
func (s *KolMarketplaceService) Subscribe(ctx context.Context, actor auth.Actor, packageID int64) (*KolSubscriptionResponse, error) {
pkg, err := s.marketplaceRepo.GetPublished(ctx, packageID)
if err != nil {
return nil, response.ErrInternal(50090, "kol_marketplace_query_failed", err.Error())
}
if pkg == nil {
return nil, errKolMarketplacePackageNotFound
}
if pkg.TenantID == actor.TenantID {
return nil, errKolSelfSubscribeViaMarket
}
existing, err := s.subRepo.GetActiveForTenant(ctx, actor.TenantID, packageID)
if err != nil {
return nil, response.ErrInternal(50093, "kol_subscription_query_failed", err.Error())
}
if existing != nil {
return nil, errKolSubscriptionExists
}
sub, err := s.subRepo.Create(ctx, actor.TenantID, packageID)
if err != nil {
if isUniqueConstraintError(err, "uk_kol_subscription_active") {
return nil, errKolSubscriptionExists
}
return nil, response.ErrInternal(50094, "kol_subscription_create_failed", err.Error())
}
InvalidateKolPublicCaches(ctx, s.cache)
return newKolSubscriptionResponse(sub), nil
}
func (s *KolMarketplaceService) ListMySubscriptions(ctx context.Context, actor auth.Actor) ([]KolSubscriptionResponse, error) {
subs, err := s.subRepo.ListByTenant(ctx, actor.TenantID)
if err != nil {
return nil, response.ErrInternal(50093, "kol_subscription_query_failed", err.Error())
}
result := make([]KolSubscriptionResponse, 0, len(subs))
for _, sub := range subs {
if resp := newKolSubscriptionResponse(&sub); resp != nil {
result = append(result, *resp)
}
}
return result, nil
}
func (s *KolMarketplaceService) ListMySubscriptionPrompts(ctx context.Context, actor auth.Actor) ([]KolSubscriptionPromptCardResponse, error) {
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, kolSubscriptionPromptListCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]KolSubscriptionPromptCardResponse, error) {
rows, err := s.subRepo.ListSubscriptionPromptsByTenant(loadCtx, actor.TenantID)
if err != nil {
return nil, response.ErrInternal(50095, "kol_subscription_prompt_query_failed", err.Error())
}
result := make([]KolSubscriptionPromptCardResponse, 0, len(rows))
for _, row := range rows {
grantedAt := ""
if row.GrantedAt != nil {
grantedAt = row.GrantedAt.Format(time.RFC3339)
}
result = append(result, KolSubscriptionPromptCardResponse{
ID: row.ID,
PackageID: row.PackageID,
PackageName: row.PackageName,
PromptID: row.PromptID,
PromptName: row.PromptName,
PlatformHint: row.PlatformHint,
KolDisplayName: row.KolDisplayName,
GrantedAt: grantedAt,
})
}
return result, nil
})
}
func newKolMarketplacePackageResponse(pkg repository.PublishedKolPackage, viewerTenantID int64) (*KolMarketplacePackageResponse, error) {
tags, err := decodeKolStringList(pkg.Tags)
if err != nil {
return nil, err
}
return &KolMarketplacePackageResponse{
ID: pkg.ID,
KolProfileID: pkg.KolProfileID,
KolDisplayName: pkg.KolDisplayName,
KolAvatarURL: pkg.KolAvatarURL,
Name: pkg.Name,
Description: pkg.Description,
CoverURL: pkg.CoverURL,
Industry: pkg.Industry,
Tags: tags,
PriceNote: pkg.PriceNote,
Status: pkg.Status,
PromptCount: int(pkg.PromptCount),
SubscriberCount: int(pkg.SubscriberCount),
OwnedByCurrentTenant: pkg.TenantID == viewerTenantID,
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
}, nil
}
func newKolSubscriptionResponse(sub *repository.KolSubscription) *KolSubscriptionResponse {
if sub == nil {
return nil
}
return &KolSubscriptionResponse{
ID: sub.ID,
PackageID: sub.PackageID,
Status: sub.Status,
StartAt: timeStringPtr(sub.StartAt),
EndAt: timeStringPtr(sub.EndAt),
CreatedAt: sub.CreatedAt.Format(time.RFC3339),
}
}
func normalizeMarketFilter(filter MarketFilter) MarketFilter {
if filter.Offset < 0 {
filter.Offset = 0
}
if filter.Limit <= 0 {
filter.Limit = 20
}
if filter.Limit > 100 {
filter.Limit = 100
}
filter.Industry = normalizeKolOptionalString(filter.Industry)
filter.Keyword = normalizeKolOptionalString(filter.Keyword)
return filter
}