8761e47f78
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>
312 lines
11 KiB
Go
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
|
|
}
|