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>
This commit is contained in:
@@ -241,6 +241,7 @@ func (s *KolSubscriptionService) invalidateAccessCaches(ctx context.Context, ten
|
|||||||
_ = s.cache.Delete(ctx, fmt.Sprintf("workspace:kol_cards:%d", tenantID))
|
_ = s.cache.Delete(ctx, fmt.Sprintf("workspace:kol_cards:%d", tenantID))
|
||||||
_ = s.cache.DeletePrefix(ctx, fmt.Sprintf("kol:subscription_prompt:list:%d:", tenantID))
|
_ = s.cache.DeletePrefix(ctx, fmt.Sprintf("kol:subscription_prompt:list:%d:", tenantID))
|
||||||
_ = s.cache.DeletePrefix(ctx, fmt.Sprintf("kol:subscription_prompt:schema:%d:", tenantID))
|
_ = s.cache.DeletePrefix(ctx, fmt.Sprintf("kol:subscription_prompt:schema:%d:", tenantID))
|
||||||
|
_ = s.cache.DeletePrefix(ctx, "kol:marketplace:")
|
||||||
}
|
}
|
||||||
|
|
||||||
func toKolSubscriptionView(item repository.KolSubscriptionRecord) KolSubscriptionView {
|
func toKolSubscriptionView(item repository.KolSubscriptionRecord) KolSubscriptionView {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ func TestKolSubscriptionInvalidateAccessCaches(t *testing.T) {
|
|||||||
wantPrefixes := []string{
|
wantPrefixes := []string{
|
||||||
"kol:subscription_prompt:list:42:",
|
"kol:subscription_prompt:list:42:",
|
||||||
"kol:subscription_prompt:schema:42:",
|
"kol:subscription_prompt:schema:42:",
|
||||||
|
"kol:marketplace:",
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(cache.deletedPrefixes, wantPrefixes) {
|
if !reflect.DeepEqual(cache.deletedPrefixes, wantPrefixes) {
|
||||||
t.Fatalf("deleted prefixes = %v, want %v", cache.deletedPrefixes, wantPrefixes)
|
t.Fatalf("deleted prefixes = %v, want %v", cache.deletedPrefixes, wantPrefixes)
|
||||||
|
|||||||
@@ -285,14 +285,49 @@ func kolPromptListCachePrefix(tenantID int64) string {
|
|||||||
return fmt.Sprintf("kol:prompt:list:%d:", tenantID)
|
return fmt.Sprintf("kol:prompt:list:%d:", tenantID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func kolMarketplaceCachePrefix() string {
|
||||||
|
return "kol:marketplace:"
|
||||||
|
}
|
||||||
|
|
||||||
|
func kolMarketplacePackageListCachePrefix() string {
|
||||||
|
return kolMarketplaceCachePrefix() + "packages:list:"
|
||||||
|
}
|
||||||
|
|
||||||
|
func kolMarketplacePackageListCacheKey(viewerTenantID int64, params interface{}) string {
|
||||||
|
return fmt.Sprintf("%s%d:%s", kolMarketplacePackageListCachePrefix(), viewerTenantID, digestCacheKey(params))
|
||||||
|
}
|
||||||
|
|
||||||
|
func kolMarketplacePackageDetailCachePrefix() string {
|
||||||
|
return kolMarketplaceCachePrefix() + "packages:detail:"
|
||||||
|
}
|
||||||
|
|
||||||
|
func kolMarketplacePackageDetailCacheKey(viewerTenantID, packageID int64) string {
|
||||||
|
return fmt.Sprintf("%s%d:%d", kolMarketplacePackageDetailCachePrefix(), viewerTenantID, packageID)
|
||||||
|
}
|
||||||
|
|
||||||
func kolSubscriptionPromptListCachePrefix(tenantID int64) string {
|
func kolSubscriptionPromptListCachePrefix(tenantID int64) string {
|
||||||
return fmt.Sprintf("kol:subscription_prompt:list:%d:", tenantID)
|
return fmt.Sprintf("kol:subscription_prompt:list:%d:", tenantID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func kolSubscriptionPromptListCacheKey(tenantID int64) string {
|
||||||
|
return kolSubscriptionPromptListCachePrefix(tenantID) + "all"
|
||||||
|
}
|
||||||
|
|
||||||
func kolSubscriptionPromptSchemaCachePrefix(tenantID int64) string {
|
func kolSubscriptionPromptSchemaCachePrefix(tenantID int64) string {
|
||||||
return fmt.Sprintf("kol:subscription_prompt:schema:%d:", tenantID)
|
return fmt.Sprintf("kol:subscription_prompt:schema:%d:", tenantID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func kolSubscriptionPromptSchemaCacheKey(tenantID, subscriptionPromptID int64) string {
|
||||||
|
return fmt.Sprintf("%s%d", kolSubscriptionPromptSchemaCachePrefix(tenantID), subscriptionPromptID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InvalidateKolPublicCaches(ctx context.Context, c sharedcache.Cache) {
|
||||||
|
deleteCachePrefix(ctx, c, kolMarketplaceCachePrefix())
|
||||||
|
deleteCachePrefix(ctx, c, "kol:subscription_prompt:list:")
|
||||||
|
deleteCachePrefix(ctx, c, "kol:subscription_prompt:schema:")
|
||||||
|
deleteCachePrefix(ctx, c, "workspace:kol_cards:")
|
||||||
|
}
|
||||||
|
|
||||||
func invalidateKolPromptCaches(ctx context.Context, c sharedcache.Cache, tenantID int64) {
|
func invalidateKolPromptCaches(ctx context.Context, c sharedcache.Cache, tenantID int64) {
|
||||||
deleteCachePrefix(ctx, c, kolPromptDetailCachePrefix(tenantID))
|
deleteCachePrefix(ctx, c, kolPromptDetailCachePrefix(tenantID))
|
||||||
deleteCachePrefix(ctx, c, fmt.Sprintf("kol:prompt:content:%d:", tenantID))
|
deleteCachePrefix(ctx, c, fmt.Sprintf("kol:prompt:content:%d:", tenantID))
|
||||||
@@ -306,7 +341,5 @@ func InvalidateKolPromptCaches(ctx context.Context, c sharedcache.Cache) {
|
|||||||
deleteCachePrefix(ctx, c, "kol:prompt:detail:")
|
deleteCachePrefix(ctx, c, "kol:prompt:detail:")
|
||||||
deleteCachePrefix(ctx, c, "kol:prompt:content:")
|
deleteCachePrefix(ctx, c, "kol:prompt:content:")
|
||||||
deleteCachePrefix(ctx, c, "kol:prompt:list:")
|
deleteCachePrefix(ctx, c, "kol:prompt:list:")
|
||||||
deleteCachePrefix(ctx, c, "kol:subscription_prompt:list:")
|
InvalidateKolPublicCaches(ctx, c)
|
||||||
deleteCachePrefix(ctx, c, "kol:subscription_prompt:schema:")
|
|
||||||
deleteCachePrefix(ctx, c, "workspace:kol_cards:")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,30 +129,32 @@ func (s *KolGenerationService) WithCache(c sharedcache.Cache) *KolGenerationServ
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *KolGenerationService) GetSchema(ctx context.Context, actor auth.Actor, subPromptID int64) (*KolSubscriptionPromptSchemaResponse, error) {
|
func (s *KolGenerationService) GetSchema(ctx context.Context, actor auth.Actor, subPromptID int64) (*KolSubscriptionPromptSchemaResponse, error) {
|
||||||
row, prompt, err := s.loadAuthorizedPrompt(ctx, actor, subPromptID)
|
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, kolSubscriptionPromptSchemaCacheKey(actor.TenantID, subPromptID), defaultCacheTTL(), func(loadCtx context.Context) (*KolSubscriptionPromptSchemaResponse, error) {
|
||||||
if err != nil {
|
row, prompt, err := s.loadAuthorizedPrompt(loadCtx, actor, subPromptID)
|
||||||
return nil, err
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
schema, err := decodeKolSchema(prompt.SchemaJSON)
|
schema, err := decodeKolSchema(prompt.SchemaJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50096, "kol_generation_schema_decode_failed", err.Error())
|
return nil, response.ErrInternal(50096, "kol_generation_schema_decode_failed", err.Error())
|
||||||
}
|
}
|
||||||
cardConfig, err := decodeKolCardConfig(prompt.CardConfigJSON)
|
cardConfig, err := decodeKolCardConfig(prompt.CardConfigJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50097, "kol_generation_card_config_decode_failed", err.Error())
|
return nil, response.ErrInternal(50097, "kol_generation_card_config_decode_failed", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return &KolSubscriptionPromptSchemaResponse{
|
return &KolSubscriptionPromptSchemaResponse{
|
||||||
SubscriptionPromptID: row.ID,
|
SubscriptionPromptID: row.ID,
|
||||||
PackageID: row.PackageID,
|
PackageID: row.PackageID,
|
||||||
PackageName: row.PackageName,
|
PackageName: row.PackageName,
|
||||||
PromptID: row.PromptID,
|
PromptID: row.PromptID,
|
||||||
PromptName: row.PromptName,
|
PromptName: row.PromptName,
|
||||||
PlatformHint: row.PlatformHint,
|
PlatformHint: row.PlatformHint,
|
||||||
SchemaJSON: schema,
|
SchemaJSON: schema,
|
||||||
CardConfigJSON: cardConfig,
|
CardConfigJSON: cardConfig,
|
||||||
}, nil
|
}, nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, subPromptID int64, req KolGenerationSubmitRequest) (*KolGenerationSubmitResponse, error) {
|
func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, subPromptID int64, req KolGenerationSubmitRequest) (*KolGenerationSubmitResponse, error) {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"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/tenant/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubKolMarketplaceRepository struct {
|
||||||
|
listCalls atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubKolMarketplaceRepository) ListPublished(ctx context.Context, filter repository.ListPublishedFilter) ([]repository.PublishedKolPackage, error) {
|
||||||
|
r.listCalls.Add(1)
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
return []repository.PublishedKolPackage{
|
||||||
|
{
|
||||||
|
ID: 7,
|
||||||
|
TenantID: 11,
|
||||||
|
KolProfileID: 13,
|
||||||
|
Name: "pkg",
|
||||||
|
Tags: []byte("[]"),
|
||||||
|
Status: "published",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
KolDisplayName: "KOL",
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubKolMarketplaceRepository) GetPublished(ctx context.Context, id int64) (*repository.PublishedKolPackage, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubKolMarketplaceRepository) ListPublicPrompts(ctx context.Context, packageID int64) ([]repository.PublicKolPrompt, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKolMarketplaceListUsesCacheAsideAndSingleflight(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cache := sharedcache.New("memory", nil, sharedcache.WithNamespace("test"), sharedcache.WithMetrics(false), sharedcache.WithJitterRatio(0))
|
||||||
|
repo := &stubKolMarketplaceRepository{}
|
||||||
|
svc := NewKolMarketplaceService(repo, nil).WithCache(cache)
|
||||||
|
actor := auth.Actor{TenantID: 42}
|
||||||
|
filter := MarketFilter{Limit: 20}
|
||||||
|
|
||||||
|
const callers = 8
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, callers)
|
||||||
|
for i := 0; i < callers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, err := svc.ListPackages(ctx, actor, filter)
|
||||||
|
errs <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPackages() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls := repo.listCalls.Load(); calls != 1 {
|
||||||
|
t.Fatalf("ListPublished calls after concurrent cold-cache load = %d, want 1", calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.ListPackages(ctx, actor, filter); err != nil {
|
||||||
|
t.Fatalf("ListPackages() cached error = %v", err)
|
||||||
|
}
|
||||||
|
if calls := repo.listCalls.Load(); calls != 1 {
|
||||||
|
t.Fatalf("ListPublished calls after cache hit = %d, want 1", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
"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/shared/response"
|
||||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -82,6 +84,8 @@ type KolSubscriptionPromptCardResponse struct {
|
|||||||
type KolMarketplaceService struct {
|
type KolMarketplaceService struct {
|
||||||
marketplaceRepo repository.KolMarketplaceRepository
|
marketplaceRepo repository.KolMarketplaceRepository
|
||||||
subRepo repository.KolSubscriptionRepository
|
subRepo repository.KolSubscriptionRepository
|
||||||
|
cache sharedcache.Cache
|
||||||
|
cacheGroup singleflight.Group
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKolMarketplaceService(
|
func NewKolMarketplaceService(
|
||||||
@@ -94,71 +98,87 @@ func NewKolMarketplaceService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func (s *KolMarketplaceService) ListPackages(ctx context.Context, actor auth.Actor, filter MarketFilter) ([]KolMarketplacePackageResponse, error) {
|
||||||
filter = normalizeMarketFilter(filter)
|
filter = normalizeMarketFilter(filter)
|
||||||
|
|
||||||
packages, err := s.marketplaceRepo.ListPublished(ctx, repository.ListPublishedFilter{
|
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, kolMarketplacePackageListCacheKey(actor.TenantID, filter), defaultCacheTTL(), func(loadCtx context.Context) ([]KolMarketplacePackageResponse, error) {
|
||||||
Industry: filter.Industry,
|
packages, err := s.marketplaceRepo.ListPublished(loadCtx, repository.ListPublishedFilter{
|
||||||
Keyword: filter.Keyword,
|
Industry: filter.Industry,
|
||||||
Offset: filter.Offset,
|
Keyword: filter.Keyword,
|
||||||
Limit: filter.Limit,
|
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 {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50091, "kol_marketplace_decode_failed", err.Error())
|
return nil, response.ErrInternal(50090, "kol_marketplace_query_failed", err.Error())
|
||||||
}
|
}
|
||||||
result = append(result, *item)
|
|
||||||
}
|
result := make([]KolMarketplacePackageResponse, 0, len(packages))
|
||||||
return result, nil
|
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) {
|
func (s *KolMarketplaceService) GetPackage(ctx context.Context, actor auth.Actor, id int64) (*KolMarketplacePackageDetailResponse, error) {
|
||||||
pkg, err := s.marketplaceRepo.GetPublished(ctx, id)
|
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 {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50090, "kol_marketplace_query_failed", err.Error())
|
return nil, err
|
||||||
}
|
}
|
||||||
if pkg == nil {
|
if !found || record == nil {
|
||||||
return nil, errKolMarketplacePackageNotFound
|
return nil, errKolMarketplacePackageNotFound
|
||||||
}
|
}
|
||||||
|
return record, nil
|
||||||
prompts, err := s.marketplaceRepo.ListPublicPrompts(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, response.ErrInternal(50092, "kol_marketplace_prompt_query_failed", err.Error())
|
|
||||||
}
|
|
||||||
sub, err := s.subRepo.GetActiveForTenant(ctx, actor.TenantID, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, response.ErrInternal(50093, "kol_subscription_query_failed", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
pkgResp, err := newKolMarketplacePackageResponse(*pkg, actor.TenantID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 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, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *KolMarketplaceService) Subscribe(ctx context.Context, actor auth.Actor, packageID int64) (*KolSubscriptionResponse, error) {
|
func (s *KolMarketplaceService) Subscribe(ctx context.Context, actor auth.Actor, packageID int64) (*KolSubscriptionResponse, error) {
|
||||||
@@ -188,6 +208,7 @@ func (s *KolMarketplaceService) Subscribe(ctx context.Context, actor auth.Actor,
|
|||||||
}
|
}
|
||||||
return nil, response.ErrInternal(50094, "kol_subscription_create_failed", err.Error())
|
return nil, response.ErrInternal(50094, "kol_subscription_create_failed", err.Error())
|
||||||
}
|
}
|
||||||
|
InvalidateKolPublicCaches(ctx, s.cache)
|
||||||
return newKolSubscriptionResponse(sub), nil
|
return newKolSubscriptionResponse(sub), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,29 +228,31 @@ func (s *KolMarketplaceService) ListMySubscriptions(ctx context.Context, actor a
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *KolMarketplaceService) ListMySubscriptionPrompts(ctx context.Context, actor auth.Actor) ([]KolSubscriptionPromptCardResponse, error) {
|
func (s *KolMarketplaceService) ListMySubscriptionPrompts(ctx context.Context, actor auth.Actor) ([]KolSubscriptionPromptCardResponse, error) {
|
||||||
rows, err := s.subRepo.ListSubscriptionPromptsByTenant(ctx, actor.TenantID)
|
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, kolSubscriptionPromptListCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]KolSubscriptionPromptCardResponse, error) {
|
||||||
if err != nil {
|
rows, err := s.subRepo.ListSubscriptionPromptsByTenant(loadCtx, actor.TenantID)
|
||||||
return nil, response.ErrInternal(50095, "kol_subscription_prompt_query_failed", err.Error())
|
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,
|
result := make([]KolSubscriptionPromptCardResponse, 0, len(rows))
|
||||||
PackageID: row.PackageID,
|
for _, row := range rows {
|
||||||
PackageName: row.PackageName,
|
grantedAt := ""
|
||||||
PromptID: row.PromptID,
|
if row.GrantedAt != nil {
|
||||||
PromptName: row.PromptName,
|
grantedAt = row.GrantedAt.Format(time.RFC3339)
|
||||||
PlatformHint: row.PlatformHint,
|
}
|
||||||
KolDisplayName: row.KolDisplayName,
|
result = append(result, KolSubscriptionPromptCardResponse{
|
||||||
GrantedAt: grantedAt,
|
ID: row.ID,
|
||||||
})
|
PackageID: row.PackageID,
|
||||||
}
|
PackageName: row.PackageName,
|
||||||
return result, nil
|
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) {
|
func newKolMarketplacePackageResponse(pkg repository.PublishedKolPackage, viewerTenantID int64) (*KolMarketplacePackageResponse, error) {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"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/tenant/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubKolProfileRepository struct {
|
||||||
|
profile repository.KolProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubKolProfileRepository) GetByUser(ctx context.Context, tenantID, userID int64) (*repository.KolProfile, error) {
|
||||||
|
if r.profile.TenantID != tenantID || r.profile.UserID != userID {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
profile := r.profile
|
||||||
|
return &profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubKolProfileRepository) GetByID(ctx context.Context, id int64) (*repository.KolProfile, error) {
|
||||||
|
if r.profile.ID != id {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
profile := r.profile
|
||||||
|
return &profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubKolProfileRepository) Update(ctx context.Context, tenantID int64, input repository.UpdateKolProfileInput) error {
|
||||||
|
if r.profile.TenantID != tenantID || r.profile.ID != input.ID {
|
||||||
|
return errors.New("unexpected profile update target")
|
||||||
|
}
|
||||||
|
r.profile.DisplayName = input.DisplayName
|
||||||
|
r.profile.Bio = input.Bio
|
||||||
|
r.profile.AvatarURL = input.AvatarURL
|
||||||
|
r.profile.UpdatedAt = time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateKolProfileInvalidatesPublicCaches(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cache := sharedcache.New("memory", nil, sharedcache.WithNamespace("test"), sharedcache.WithMetrics(false), sharedcache.WithJitterRatio(0))
|
||||||
|
tenantID := int64(42)
|
||||||
|
|
||||||
|
keys := []string{
|
||||||
|
workspaceKolCardsCacheKey(tenantID),
|
||||||
|
kolMarketplacePackageListCacheKey(tenantID, MarketFilter{Limit: 20}),
|
||||||
|
kolMarketplacePackageDetailCacheKey(tenantID, 7),
|
||||||
|
kolSubscriptionPromptListCacheKey(tenantID),
|
||||||
|
kolSubscriptionPromptSchemaCacheKey(tenantID, 9),
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
if err := cache.Set(ctx, key, []byte(`{"value":"stale"}`), time.Minute); err != nil {
|
||||||
|
t.Fatalf("seed cache %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := &stubKolProfileRepository{profile: repository.KolProfile{
|
||||||
|
ID: 3,
|
||||||
|
TenantID: tenantID,
|
||||||
|
UserID: 5,
|
||||||
|
DisplayName: "old name",
|
||||||
|
MarketEnabled: true,
|
||||||
|
Status: "active",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}}
|
||||||
|
svc := NewKolProfileService(repo).WithCache(cache)
|
||||||
|
|
||||||
|
if _, err := svc.UpdateProfile(ctx, auth.Actor{TenantID: tenantID, UserID: 5}, UpdateKolProfileServiceInput{DisplayName: "new name"}); err != nil {
|
||||||
|
t.Fatalf("UpdateProfile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
if _, err := cache.Get(ctx, key); !errors.Is(err, sharedcache.ErrNotFound) {
|
||||||
|
t.Fatalf("cache key %s should be invalidated, got err %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
"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/objectstorage"
|
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||||
@@ -15,6 +16,7 @@ var ErrNotActiveKol = response.ErrForbidden(40341, "kol_not_active", "user is no
|
|||||||
type KolProfileService struct {
|
type KolProfileService struct {
|
||||||
repo repository.KolProfileRepository
|
repo repository.KolProfileRepository
|
||||||
objectStorage objectstorage.Client
|
objectStorage objectstorage.Client
|
||||||
|
cache sharedcache.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateKolProfileServiceInput struct {
|
type UpdateKolProfileServiceInput struct {
|
||||||
@@ -45,6 +47,11 @@ func (s *KolProfileService) WithObjectStorage(client objectstorage.Client) *KolP
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *KolProfileService) WithCache(c sharedcache.Cache) *KolProfileService {
|
||||||
|
s.cache = c
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func (s *KolProfileService) RequireActiveKol(ctx context.Context, actor auth.Actor) (*repository.KolProfile, error) {
|
func (s *KolProfileService) RequireActiveKol(ctx context.Context, actor auth.Actor) (*repository.KolProfile, error) {
|
||||||
profile, err := s.repo.GetByUser(ctx, actor.TenantID, actor.UserID)
|
profile, err := s.repo.GetByUser(ctx, actor.TenantID, actor.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -91,6 +98,7 @@ func (s *KolProfileService) UpdateProfile(ctx context.Context, actor auth.Actor,
|
|||||||
if updated == nil {
|
if updated == nil {
|
||||||
return nil, ErrNotActiveKol
|
return nil, ErrNotActiveKol
|
||||||
}
|
}
|
||||||
|
InvalidateKolPublicCaches(ctx, s.cache)
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ type kolAssistStreamEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewKolManageHandler(a *bootstrap.App, assets *AssetHandler) *KolManageHandler {
|
func NewKolManageHandler(a *bootstrap.App, assets *AssetHandler) *KolManageHandler {
|
||||||
profileSvc := app.NewKolProfileService(a.KolProfiles).WithObjectStorage(a.ObjectStorage)
|
profileSvc := app.NewKolProfileService(a.KolProfiles).WithObjectStorage(a.ObjectStorage).WithCache(a.Cache)
|
||||||
|
|
||||||
return &KolManageHandler{
|
return &KolManageHandler{
|
||||||
profileSvc: profileSvc,
|
profileSvc: profileSvc,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type KolMarketplaceHandler struct {
|
|||||||
|
|
||||||
func NewKolMarketplaceHandler(a *bootstrap.App) *KolMarketplaceHandler {
|
func NewKolMarketplaceHandler(a *bootstrap.App) *KolMarketplaceHandler {
|
||||||
return &KolMarketplaceHandler{
|
return &KolMarketplaceHandler{
|
||||||
svc: app.NewKolMarketplaceService(a.KolMarketplace, a.KolSubscriptions),
|
svc: app.NewKolMarketplaceService(a.KolMarketplace, a.KolSubscriptions).WithCache(a.Cache),
|
||||||
genSvc: app.NewKolGenerationService(
|
genSvc: app.NewKolGenerationService(
|
||||||
a.DB,
|
a.DB,
|
||||||
a.KolSubscriptions,
|
a.KolSubscriptions,
|
||||||
|
|||||||
Reference in New Issue
Block a user